mirror of https://github.com/portainer/portainer
feat(OAuth): Add SSO support for OAuth EE-390 (#5087)
* add updateSettingsToDB28 func and test * update DBversion const * migration func naming modification * feat(oauth): add sso, hide internal auth teaser and logout options. (#5039) * cleanup and make helper func for unit testing * dbversion update * feat(publicSettings): public settings response modification for OAuth SSO EE-608 (#5062) * feat(oauth): updated logout logic with logoutUrl. (#5064) * add exclusive token generation for OAuth * swagger annotation revision * add unit test * updates based on tech review feedback * feat(oauth): updated oauth settings model * feat(oauth): added oauth logout url * feat(oauth): fixed SSO toggle and logout issue. * set SSO to ON by default * update migrator unit test * set SSO to true by default for new instance * prevent applying the SSO logout url to the initial admin user Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com> Co-authored-by: Felix Han <felix.han@portainer.io>pull/4444/merge
parent
14ac005627
commit
f674573cdf
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||||
}
|
}
|
||||||
|
|
|
@ -165,6 +165,7 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
|
||||||
settings.SnapshotInterval = *flags.SnapshotInterval
|
settings.SnapshotInterval = *flags.SnapshotInterval
|
||||||
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
||||||
settings.EnableTelemetry = true
|
settings.EnableTelemetry = true
|
||||||
|
settings.OAuthSettings.SSO = true
|
||||||
|
|
||||||
if *flags.Templates != "" {
|
if *flags.Templates != "" {
|
||||||
settings.TemplatesURL = *flags.Templates
|
settings.TemplatesURL = *flags.Templates
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
"github.com/asaskevich/govalidator"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
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 {
|
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||||
tokenData := &portainer.TokenData{
|
return handler.persistAndWriteToken(w, composeTokenData(user))
|
||||||
ID: user.ID,
|
}
|
||||||
Username: user.Username,
|
|
||||||
Role: user.Role,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
|
||||||
token, err := handler.JWTService.GenerateToken(tokenData)
|
token, err := handler.JWTService.GenerateToken(tokenData)
|
||||||
if err != nil {
|
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})
|
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||||
|
@ -204,3 +207,11 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func composeTokenData(user *portainer.User) *portainer.TokenData {
|
||||||
|
return &portainer.TokenData{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
"github.com/asaskevich/govalidator"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
@ -25,7 +26,24 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||||
return nil
|
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
|
// @summary Authenticate with OAuth
|
||||||
// @tags auth
|
// @tags auth
|
||||||
// @accept json
|
// @accept json
|
||||||
|
@ -36,52 +54,35 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||||
// @failure 422 "Invalid Credentials"
|
// @failure 422 "Invalid Credentials"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /auth/oauth/validate [post]
|
// @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 {
|
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
var payload oauthPayload
|
var payload oauthPayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
if err != nil {
|
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()
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
if err != nil {
|
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 {
|
if settings.AuthenticationMethod != portainer.AuthenticationOAuth {
|
||||||
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")}
|
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 {
|
if err != nil {
|
||||||
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
|
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)
|
user, err := handler.DataStore.User().UserByUsername(username)
|
||||||
if err != nil && err != bolterrors.ErrObjectNotFound {
|
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 {
|
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 {
|
if user == nil {
|
||||||
|
@ -92,7 +93,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||||
|
|
||||||
err = handler.DataStore.User().CreateUser(user)
|
err = handler.DataStore.User().CreateUser(user)
|
||||||
if err != nil {
|
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 {
|
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)
|
err = handler.DataStore.TeamMembership().CreateTeamMembership(membership)
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@ type publicSettingsResponse struct {
|
||||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
|
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
|
||||||
// The URL used for oauth login
|
// The URL used for oauth login
|
||||||
OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"`
|
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
|
// Whether telemetry is enabled
|
||||||
EnableTelemetry bool `json:"EnableTelemetry" example:"true"`
|
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 {
|
func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve the settings from the database", Err: 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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicSettings := generatePublicSettings(settings)
|
||||||
return response.JSON(w, publicSettings)
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ package jwt
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
@ -51,23 +51,13 @@ func NewService(userSessionDuration string) (*Service, error) {
|
||||||
|
|
||||||
// GenerateToken generates a new JWT token.
|
// GenerateToken generates a new JWT token.
|
||||||
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
||||||
expireToken := time.Now().Add(service.userSessionTimeout).Unix()
|
return service.generateSignedToken(data, nil)
|
||||||
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)
|
// GenerateTokenForOAuth generates a new JWT for OAuth login
|
||||||
if err != nil {
|
// token expiry time from the OAuth provider is considered
|
||||||
return "", err
|
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
||||||
}
|
return service.generateSignedToken(data, expiryTime)
|
||||||
|
|
||||||
return signedToken, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
|
// 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) {
|
func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) {
|
||||||
service.userSessionTimeout = userSessionDuration
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -4,14 +4,16 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"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
|
// 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.
|
// 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.
|
// from the resource server and matching it with the user identifier setting.
|
||||||
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, *time.Time, error) {
|
||||||
token, err := getAccessToken(code, configuration)
|
token, err := getOAuthToken(code, configuration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
|
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
|
||||||
return "", err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
username, err := getUsername(token.AccessToken, configuration)
|
||||||
return getUsername(token, 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)
|
unescapedCode, err := url.QueryUnescape(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
config := buildConfig(configuration)
|
config := buildConfig(configuration)
|
||||||
token, err := config.Exchange(context.Background(), unescapedCode)
|
token, err := config.Exchange(context.Background(), unescapedCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return token.AccessToken, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) {
|
func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) {
|
||||||
|
|
|
@ -489,6 +489,8 @@ type (
|
||||||
Scopes string `json:"Scopes"`
|
Scopes string `json:"Scopes"`
|
||||||
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
|
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
|
||||||
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
||||||
|
SSO bool `json:"SSO"`
|
||||||
|
LogoutURI string `json:"LogoutURI"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair defines a key/value string pair
|
// Pair defines a key/value string pair
|
||||||
|
@ -1145,6 +1147,7 @@ type (
|
||||||
// JWTService represents a service for managing JWT tokens
|
// JWTService represents a service for managing JWT tokens
|
||||||
JWTService interface {
|
JWTService interface {
|
||||||
GenerateToken(data *TokenData) (string, error)
|
GenerateToken(data *TokenData) (string, error)
|
||||||
|
GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error)
|
||||||
ParseAndVerifyToken(token string) (*TokenData, error)
|
ParseAndVerifyToken(token string) (*TokenData, error)
|
||||||
SetUserSessionDuration(userSessionDuration time.Duration)
|
SetUserSessionDuration(userSessionDuration time.Duration)
|
||||||
}
|
}
|
||||||
|
@ -1176,7 +1179,7 @@ type (
|
||||||
|
|
||||||
// OAuthService represents a service used to authenticate users using OAuth
|
// OAuthService represents a service used to authenticate users using OAuth
|
||||||
OAuthService interface {
|
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
|
// RegistryService represents a service for managing registry data
|
||||||
|
@ -1330,7 +1333,7 @@ const (
|
||||||
// APIVersion is the version number of the Portainer API
|
// APIVersion is the version number of the Portainer API
|
||||||
APIVersion = "2.5.1"
|
APIVersion = "2.5.1"
|
||||||
// DBVersion is the version number of the Portainer database
|
// 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 is a maximum supported version of the docker compose syntax
|
||||||
ComposeSyntaxMaxVersion = "3.9"
|
ComposeSyntaxMaxVersion = "3.9"
|
||||||
// AssetsServerURL represents the URL of the Portainer asset server
|
// AssetsServerURL represents the URL of the Portainer asset server
|
||||||
|
|
|
@ -18,6 +18,7 @@ export function PublicSettingsViewModel(settings) {
|
||||||
this.LogoURL = settings.LogoURL;
|
this.LogoURL = settings.LogoURL;
|
||||||
this.OAuthLoginURI = settings.OAuthLoginURI;
|
this.OAuthLoginURI = settings.OAuthLoginURI;
|
||||||
this.EnableTelemetry = settings.EnableTelemetry;
|
this.EnableTelemetry = settings.EnableTelemetry;
|
||||||
|
this.OAuthLogoutURI = settings.OAuthLogoutURI;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LDAPSettingsViewModel(data) {
|
export function LDAPSettingsViewModel(data) {
|
||||||
|
@ -52,4 +53,6 @@ export function OAuthSettingsViewModel(data) {
|
||||||
this.Scopes = data.Scopes;
|
this.Scopes = data.Scopes;
|
||||||
this.OAuthAutoCreateUsers = data.OAuthAutoCreateUsers;
|
this.OAuthAutoCreateUsers = data.OAuthAutoCreateUsers;
|
||||||
this.DefaultTeamID = data.DefaultTeamID;
|
this.DefaultTeamID = data.DefaultTeamID;
|
||||||
|
this.SSO = data.SSO;
|
||||||
|
this.LogoutURI = data.LogoutURI;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,32 @@
|
||||||
<div>
|
<div
|
||||||
|
><div class="col-sm-12 form-section-title">
|
||||||
|
Single Sign-On
|
||||||
|
</div>
|
||||||
|
<!-- SSO -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="use-sso" class="control-label col-sm-2 text-left" style="padding-top: 0;">
|
||||||
|
Use SSO
|
||||||
|
<portainer-tooltip position="bottom" message="When using SSO the OAuth provider is not forced to prompt for credentials."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<label class="switch"> <input id="use-sso" type="checkbox" ng-model="$ctrl.settings.SSO" /><i></i> </label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !SSO -->
|
||||||
|
|
||||||
|
<!-- HideInternalAuth -->
|
||||||
|
<div class="form-group" ng-if="$ctrl.settings.SSO">
|
||||||
|
<label for="hide-internal-auth" class="control-label col-sm-2 text-left" style="padding-top: 0;">
|
||||||
|
Hide internal authentication prompt
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<label class="switch"> <input id="hide-internal-auth" type="checkbox" disabled /><i></i> </label>
|
||||||
|
<span class="text-muted small" style="margin-left: 15px;">
|
||||||
|
This feature is available in <a href="https://www.portainer.io/business-upsell?from=hide-internal-auth" target="_blank"> Portainer Business Edition</a>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !HideInternalAuth -->
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Automatic user provisioning
|
Automatic user provisioning
|
||||||
</div>
|
</div>
|
||||||
|
@ -105,7 +133,18 @@
|
||||||
<input type="text" class="form-control" id="oauth_redirect_uri" ng-model="$ctrl.settings.RedirectURI" placeholder="http://yourportainer.com/" />
|
<input type="text" class="form-control" id="oauth_redirect_uri" ng-model="$ctrl.settings.RedirectURI" placeholder="http://yourportainer.com/" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="oauth_logout_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Logout URL
|
||||||
|
<portainer-tooltip
|
||||||
|
position="bottom"
|
||||||
|
message="URL used by Portainer to redirect the user to the OAuth provider in order to log the user out of the identity provider session."
|
||||||
|
></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" id="oauth_logout_url" ng-model="$ctrl.settings.LogoutURI" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
|
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
User identifier
|
User identifier
|
||||||
|
|
|
@ -2,15 +2,17 @@ import angular from 'angular';
|
||||||
|
|
||||||
class LogoutController {
|
class LogoutController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($async, $state, $transition$, Authentication, StateManager, Notifications, LocalStorage) {
|
constructor($async, $state, $transition$, $window, Authentication, StateManager, Notifications, LocalStorage, SettingsService) {
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.$state = $state;
|
this.$state = $state;
|
||||||
this.$transition$ = $transition$;
|
this.$transition$ = $transition$;
|
||||||
|
this.$window = $window;
|
||||||
|
|
||||||
this.Authentication = Authentication;
|
this.Authentication = Authentication;
|
||||||
this.StateManager = StateManager;
|
this.StateManager = StateManager;
|
||||||
this.Notifications = Notifications;
|
this.Notifications = Notifications;
|
||||||
this.LocalStorage = LocalStorage;
|
this.LocalStorage = LocalStorage;
|
||||||
|
this.SettingsService = SettingsService;
|
||||||
|
|
||||||
this.logo = this.StateManager.getState().application.logo;
|
this.logo = this.StateManager.getState().application.logo;
|
||||||
this.logoutAsync = this.logoutAsync.bind(this);
|
this.logoutAsync = this.logoutAsync.bind(this);
|
||||||
|
@ -24,13 +26,19 @@ class LogoutController {
|
||||||
async logoutAsync() {
|
async logoutAsync() {
|
||||||
const error = this.$transition$.params().error;
|
const error = this.$transition$.params().error;
|
||||||
const performApiLogout = this.$transition$.params().performApiLogout;
|
const performApiLogout = this.$transition$.params().performApiLogout;
|
||||||
|
const settings = await this.SettingsService.publicSettings();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.Authentication.logout(performApiLogout);
|
await this.Authentication.logout(performApiLogout);
|
||||||
} finally {
|
} finally {
|
||||||
this.LocalStorage.storeLogoutReason(error);
|
this.LocalStorage.storeLogoutReason(error);
|
||||||
|
if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) {
|
||||||
|
this.$window.location.href = settings.OAuthLogoutURI;
|
||||||
|
} else {
|
||||||
this.$state.go('portainer.auth', { reload: true });
|
this.$state.go('portainer.auth', { reload: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
return this.$async(this.logoutAsync);
|
return this.$async(this.logoutAsync);
|
||||||
|
|
Loading…
Reference in New Issue