diff --git a/api/bolt/migrator/migrate_dbversion30.go b/api/bolt/migrator/migrate_dbversion30.go new file mode 100644 index 000000000..47543fe13 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion30.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToDB31() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + legacySettings.OAuthSettings.SSO = false + legacySettings.OAuthSettings.LogoutURI = "" + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrate_dbversion30_test.go b/api/bolt/migrator/migrate_dbversion30_test.go new file mode 100644 index 000000000..d4597fffa --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion30_test.go @@ -0,0 +1,64 @@ +package migrator + +import ( + "os" + "testing" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer/api/bolt/settings" +) + +var ( + testingDBStorePath string + testingDBFileName string + dummyLogoURL string + dbConn *bolt.DB + settingsService *settings.Service +) + +func setup() error { + testingDBStorePath, _ = os.Getwd() + testingDBFileName = "portainer-ee-mig-30.db" + dummyLogoURL = "example.com" + var err error + dbConn, err = initTestingDBConn(testingDBStorePath, testingDBFileName) + if err != nil { + return err + } + dummySettingsObj := map[string]interface{}{ + "LogoURL": dummyLogoURL, + } + settingsService, err = initTestingSettingsService(dbConn, dummySettingsObj) + if err != nil { + return err + } + return nil +} + +func TestUpdateSettingsToDB31(t *testing.T) { + if err := setup(); err != nil { + t.Errorf("failed to complete testing setups, err: %v", err) + } + defer dbConn.Close() + defer os.Remove(testingDBFileName) + m := &Migrator{ + db: dbConn, + settingsService: settingsService, + } + if err := m.updateSettingsToDB31(); err != nil { + t.Errorf("failed to update settings: %v", err) + } + updatedSettings, err := m.settingsService.Settings() + if err != nil { + t.Errorf("failed to retrieve the updated settings: %v", err) + } + if updatedSettings.LogoURL != dummyLogoURL { + t.Errorf("unexpected value changes in the updated settings, want LogoURL value: %s, got LogoURL value: %s", dummyLogoURL, updatedSettings.LogoURL) + } + if updatedSettings.OAuthSettings.SSO != false { + t.Errorf("unexpected default OAuth SSO setting, want: false, got: %t", updatedSettings.OAuthSettings.SSO) + } + if updatedSettings.OAuthSettings.LogoutURI != "" { + t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI) + } +} diff --git a/api/bolt/migrator/migrate_test_helper.go b/api/bolt/migrator/migrate_test_helper.go new file mode 100644 index 000000000..5b02c9d6a --- /dev/null +++ b/api/bolt/migrator/migrate_test_helper.go @@ -0,0 +1,38 @@ +package migrator + +import ( + "path" + "time" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer/api/bolt/internal" + "github.com/portainer/portainer/api/bolt/settings" +) + +// initTestingDBConn creates a raw bolt DB connection +// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg +func initTestingDBConn(storePath, fileName string) (*bolt.DB, error) { + databasePath := path.Join(storePath, fileName) + dbConn, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return nil, err + } + return dbConn, nil +} + +// initTestingDBConn creates a settings service with raw bolt DB connection +// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg +func initTestingSettingsService(dbConn *bolt.DB, preSetObj map[string]interface{}) (*settings.Service, error) { + internalDBConn := &internal.DbConnection{ + DB: dbConn, + } + settingsService, err := settings.NewService(internalDBConn) + if err != nil { + return nil, err + } + //insert a obj + if err := internal.UpdateObject(internalDBConn, "settings", []byte("SETTINGS"), preSetObj); err != nil { + return nil, err + } + return settingsService, nil +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index e366bd3df..9e8499379 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -358,5 +358,13 @@ func (m *Migrator) Migrate() error { } } + // Portainer 2.5.0 + if m.currentDBVersion < 31 { + err := m.updateSettingsToDB31() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 8945fea5f..83b1fb3c3 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -165,6 +165,7 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI settings.SnapshotInterval = *flags.SnapshotInterval settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures settings.EnableTelemetry = true + settings.OAuthSettings.SSO = true if *flags.Templates != "" { settings.TemplatesURL = *flags.Templates diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index d7a398b79..8f2575cf4 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -5,6 +5,7 @@ import ( "log" "net/http" "strings" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -130,19 +131,21 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use } func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { - tokenData := &portainer.TokenData{ - ID: user.ID, - Username: user.Username, - Role: user.Role, - } + return handler.persistAndWriteToken(w, composeTokenData(user)) +} - return handler.persistAndWriteToken(w, tokenData) +func (handler *Handler) writeTokenForOAuth(w http.ResponseWriter, user *portainer.User, expiryTime *time.Time) *httperror.HandlerError { + token, err := handler.JWTService.GenerateTokenForOAuth(composeTokenData(user), expiryTime) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err} + } + return response.JSON(w, &authenticateResponse{JWT: token}) } func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError { token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err} } return response.JSON(w, &authenticateResponse{JWT: token}) @@ -204,3 +207,11 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM } return false } + +func composeTokenData(user *portainer.User) *portainer.TokenData { + return &portainer.TokenData{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + } +} diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 627c24878..e5b7e7885 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -4,6 +4,7 @@ import ( "errors" "log" "net/http" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -25,7 +26,24 @@ func (payload *oauthPayload) Validate(r *http.Request) error { return nil } -// @id AuthenticateOauth +func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, *time.Time, error) { + if code == "" { + return "", nil, errors.New("Invalid OAuth authorization code") + } + + if settings == nil { + return "", nil, errors.New("Invalid OAuth configuration") + } + + username, expiryTime, err := handler.OAuthService.Authenticate(code, settings) + if err != nil { + return "", nil, err + } + + return username, expiryTime, nil +} + +// @id ValidateOAuth // @summary Authenticate with OAuth // @tags auth // @accept json @@ -36,52 +54,35 @@ func (payload *oauthPayload) Validate(r *http.Request) error { // @failure 422 "Invalid Credentials" // @failure 500 "Server error" // @router /auth/oauth/validate [post] -func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) { - if code == "" { - return "", errors.New("Invalid OAuth authorization code") - } - - if settings == nil { - return "", errors.New("Invalid OAuth configuration") - } - - username, err := handler.OAuthService.Authenticate(code, settings) - if err != nil { - return "", err - } - - return username, nil -} - func (handler *Handler) validateOAuth(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} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } settings, err := handler.DataStore.Settings().Settings() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings from the database", Err: err} } - if settings.AuthenticationMethod != 3 { - return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")} + if settings.AuthenticationMethod != portainer.AuthenticationOAuth { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "OAuth authentication is not enabled", Err: errors.New("OAuth authentication is not enabled")} } - username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings) + username, expiryTime, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings) if err != nil { log.Printf("[DEBUG] - OAuth authentication error: %s", err) - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to authenticate through OAuth", Err: httperrors.ErrUnauthorized} } user, err := handler.DataStore.User().UserByUsername(username) if err != nil && err != bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a user with the specified username from the database", Err: err} } if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { - return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", httperrors.ErrUnauthorized} + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Account not created beforehand in Portainer and automatic user provisioning not enabled", Err: httperrors.ErrUnauthorized} } if user == nil { @@ -92,7 +93,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h err = handler.DataStore.User().CreateUser(user) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist user inside the database", Err: err} } if settings.OAuthSettings.DefaultTeamID != 0 { @@ -104,11 +105,11 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h err = handler.DataStore.TeamMembership().CreateTeamMembership(membership) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist team membership inside the database", Err: err} } } } - return handler.writeToken(w, user) + return handler.writeTokenForOAuth(w, user, expiryTime) } diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 75bb75a11..aab33e59e 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -18,6 +18,8 @@ type publicSettingsResponse struct { EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"` // The URL used for oauth login OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"` + // The URL used for oauth logout + OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"` // Whether telemetry is enabled EnableTelemetry bool `json:"EnableTelemetry" example:"true"` } @@ -34,20 +36,32 @@ type publicSettingsResponse struct { func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { settings, err := handler.DataStore.Settings().Settings() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} - } - - publicSettings := &publicSettingsResponse{ - LogoURL: settings.LogoURL, - AuthenticationMethod: settings.AuthenticationMethod, - EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, - EnableTelemetry: settings.EnableTelemetry, - OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", - settings.OAuthSettings.AuthorizationURI, - settings.OAuthSettings.ClientID, - settings.OAuthSettings.RedirectURI, - settings.OAuthSettings.Scopes), + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve the settings from the database", Err: err} } + publicSettings := generatePublicSettings(settings) return response.JSON(w, publicSettings) } + +func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResponse { + publicSettings := &publicSettingsResponse{ + LogoURL: appSettings.LogoURL, + AuthenticationMethod: appSettings.AuthenticationMethod, + EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures, + EnableTelemetry: appSettings.EnableTelemetry, + } + //if OAuth authentication is on, compose the related fields from application settings + if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth { + publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI + publicSettings.OAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", + appSettings.OAuthSettings.AuthorizationURI, + appSettings.OAuthSettings.ClientID, + appSettings.OAuthSettings.RedirectURI, + appSettings.OAuthSettings.Scopes) + //control prompt=login param according to the SSO setting + if !appSettings.OAuthSettings.SSO { + publicSettings.OAuthLoginURI += "&prompt=login" + } + } + return publicSettings +} diff --git a/api/http/handler/settings/settings_public_test.go b/api/http/handler/settings/settings_public_test.go new file mode 100644 index 000000000..65d331747 --- /dev/null +++ b/api/http/handler/settings/settings_public_test.go @@ -0,0 +1,70 @@ +package settings + +import ( + "fmt" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +const ( + dummyOAuthClientID = "1a2b3c4d" + dummyOAuthScopes = "scopes" + dummyOAuthAuthenticationURI = "example.com/auth" + dummyOAuthRedirectURI = "example.com/redirect" + dummyOAuthLogoutURI = "example.com/logout" +) + +var ( + dummyOAuthLoginURI string + mockAppSettings *portainer.Settings +) + +func setup() { + dummyOAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", + dummyOAuthAuthenticationURI, + dummyOAuthClientID, + dummyOAuthRedirectURI, + dummyOAuthScopes) + mockAppSettings = &portainer.Settings{ + AuthenticationMethod: portainer.AuthenticationOAuth, + OAuthSettings: portainer.OAuthSettings{ + AuthorizationURI: dummyOAuthAuthenticationURI, + ClientID: dummyOAuthClientID, + Scopes: dummyOAuthScopes, + RedirectURI: dummyOAuthRedirectURI, + LogoutURI: dummyOAuthLogoutURI, + }, + } +} + +func TestGeneratePublicSettingsWithSSO(t *testing.T) { + setup() + mockAppSettings.OAuthSettings.SSO = true + publicSettings := generatePublicSettings(mockAppSettings) + if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth { + t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod) + } + if publicSettings.OAuthLoginURI != dummyOAuthLoginURI { + t.Errorf("wrong OAuthLoginURI when SSO is switched on, want: %s, got: %s", dummyOAuthLoginURI, publicSettings.OAuthLoginURI) + } + if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI { + t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI) + } +} + +func TestGeneratePublicSettingsWithoutSSO(t *testing.T) { + setup() + mockAppSettings.OAuthSettings.SSO = false + publicSettings := generatePublicSettings(mockAppSettings) + if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth { + t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod) + } + expectedOAuthLoginURI := dummyOAuthLoginURI + "&prompt=login" + if publicSettings.OAuthLoginURI != expectedOAuthLoginURI { + t.Errorf("wrong OAuthLoginURI when SSO is switched off, want: %s, got: %s", expectedOAuthLoginURI, publicSettings.OAuthLoginURI) + } + if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI { + t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI) + } +} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 4bd9f8fec..2caf0840a 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -3,7 +3,7 @@ package jwt import ( "errors" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "fmt" "time" @@ -51,23 +51,13 @@ func NewService(userSessionDuration string) (*Service, error) { // GenerateToken generates a new JWT token. func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { - expireToken := time.Now().Add(service.userSessionTimeout).Unix() - cl := claims{ - UserID: int(data.ID), - Username: data.Username, - Role: int(data.Role), - StandardClaims: jwt.StandardClaims{ - ExpiresAt: expireToken, - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) + return service.generateSignedToken(data, nil) +} - signedToken, err := token.SignedString(service.secret) - if err != nil { - return "", err - } - - return signedToken, nil +// GenerateTokenForOAuth generates a new JWT for OAuth login +// token expiry time from the OAuth provider is considered +func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) { + return service.generateSignedToken(data, expiryTime) } // ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. @@ -97,3 +87,26 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) { service.userSessionTimeout = userSessionDuration } + +func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) { + expireToken := time.Now().Add(service.userSessionTimeout).Unix() + if expiryTime != nil && !expiryTime.IsZero() { + expireToken = expiryTime.Unix() + } + cl := claims{ + UserID: int(data.ID), + Username: data.Username, + Role: int(data.Role), + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expireToken, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) + + signedToken, err := token.SignedString(service.secret) + if err != nil { + return "", err + } + + return signedToken, nil +} diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go new file mode 100644 index 000000000..ce70f6308 --- /dev/null +++ b/api/jwt/jwt_test.go @@ -0,0 +1,38 @@ +package jwt + +import ( + "testing" + "time" + + "github.com/dgrijalva/jwt-go" + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestGenerateSignedToken(t *testing.T) { + svc, err := NewService("24h") + assert.NoError(t, err, "failed to create a copy of service") + + token := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + expirtationTime := time.Now().Add(1 * time.Hour) + + generatedToken, err := svc.generateSignedToken(token, &expirtationTime) + assert.NoError(t, err, "failed to generate a signed token") + + parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) { + return svc.secret, nil + }) + assert.NoError(t, err, "failed to parse generated token") + + tokenClaims, ok := parsedToken.Claims.(*claims) + assert.Equal(t, true, ok, "failed to claims out of generated ticket") + + assert.Equal(t, token.Username, tokenClaims.Username) + assert.Equal(t, int(token.ID), tokenClaims.UserID) + assert.Equal(t, int(token.Role), tokenClaims.Role) + assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt) +} diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index f0bf6b102..aff93bcfb 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -4,14 +4,16 @@ import ( "context" "encoding/json" "fmt" - "golang.org/x/oauth2" "io/ioutil" "log" "mime" "net/http" "net/url" + "time" - "github.com/portainer/portainer/api" + "golang.org/x/oauth2" + + portainer "github.com/portainer/portainer/api" ) // Service represents a service used to authenticate users against an authorization server @@ -23,31 +25,35 @@ func NewService() *Service { } // Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint. -// On success, it will then return the username associated to authenticated user by fetching this information +// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information // from the resource server and matching it with the user identifier setting. -func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) { - token, err := getAccessToken(code, configuration) +func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, *time.Time, error) { + token, err := getOAuthToken(code, configuration) if err != nil { log.Printf("[DEBUG] - Failed retrieving access token: %v", err) - return "", err + return "", nil, err } - - return getUsername(token, configuration) + username, err := getUsername(token.AccessToken, configuration) + if err != nil { + log.Printf("[DEBUG] - Failed retrieving oauth user name: %v", err) + return "", nil, err + } + return username, &token.Expiry, nil } -func getAccessToken(code string, configuration *portainer.OAuthSettings) (string, error) { +func getOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) { unescapedCode, err := url.QueryUnescape(code) if err != nil { - return "", err + return nil, err } config := buildConfig(configuration) token, err := config.Exchange(context.Background(), unescapedCode) if err != nil { - return "", err + return nil, err } - return token.AccessToken, nil + return token, nil } func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) { diff --git a/api/portainer.go b/api/portainer.go index 1b11b7783..6567420ad 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -489,6 +489,8 @@ type ( Scopes string `json:"Scopes"` OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` DefaultTeamID TeamID `json:"DefaultTeamID"` + SSO bool `json:"SSO"` + LogoutURI string `json:"LogoutURI"` } // Pair defines a key/value string pair @@ -1145,6 +1147,7 @@ type ( // JWTService represents a service for managing JWT tokens JWTService interface { GenerateToken(data *TokenData) (string, error) + GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error) ParseAndVerifyToken(token string) (*TokenData, error) SetUserSessionDuration(userSessionDuration time.Duration) } @@ -1176,7 +1179,7 @@ type ( // OAuthService represents a service used to authenticate users using OAuth OAuthService interface { - Authenticate(code string, configuration *OAuthSettings) (string, error) + Authenticate(code string, configuration *OAuthSettings) (string, *time.Time, error) } // RegistryService represents a service for managing registry data @@ -1330,7 +1333,7 @@ const ( // APIVersion is the version number of the Portainer API APIVersion = "2.5.1" // DBVersion is the version number of the Portainer database - DBVersion = 27 + DBVersion = 31 // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax ComposeSyntaxMaxVersion = "3.9" // AssetsServerURL represents the URL of the Portainer asset server diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index a15b6ba04..0be1a8c69 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -18,6 +18,7 @@ export function PublicSettingsViewModel(settings) { this.LogoURL = settings.LogoURL; this.OAuthLoginURI = settings.OAuthLoginURI; this.EnableTelemetry = settings.EnableTelemetry; + this.OAuthLogoutURI = settings.OAuthLogoutURI; } export function LDAPSettingsViewModel(data) { @@ -52,4 +53,6 @@ export function OAuthSettingsViewModel(data) { this.Scopes = data.Scopes; this.OAuthAutoCreateUsers = data.OAuthAutoCreateUsers; this.DefaultTeamID = data.DefaultTeamID; + this.SSO = data.SSO; + this.LogoutURI = data.LogoutURI; } diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.html b/app/portainer/oauth/components/oauth-settings/oauth-settings.html index ead2b6fca..c677d22e2 100644 --- a/app/portainer/oauth/components/oauth-settings/oauth-settings.html +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.html @@ -1,4 +1,32 @@ -