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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
"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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
"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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
Automatic user provisioning
|
||||
</div>
|
||||
|
@ -105,7 +133,18 @@
|
|||
<input type="text" class="form-control" id="oauth_redirect_uri" ng-model="$ctrl.settings.RedirectURI" placeholder="http://yourportainer.com/" />
|
||||
</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">
|
||||
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
User identifier
|
||||
|
|
|
@ -2,15 +2,17 @@ import angular from 'angular';
|
|||
|
||||
class LogoutController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $transition$, Authentication, StateManager, Notifications, LocalStorage) {
|
||||
constructor($async, $state, $transition$, $window, Authentication, StateManager, Notifications, LocalStorage, SettingsService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$transition$ = $transition$;
|
||||
this.$window = $window;
|
||||
|
||||
this.Authentication = Authentication;
|
||||
this.StateManager = StateManager;
|
||||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.SettingsService = SettingsService;
|
||||
|
||||
this.logo = this.StateManager.getState().application.logo;
|
||||
this.logoutAsync = this.logoutAsync.bind(this);
|
||||
|
@ -24,11 +26,17 @@ class LogoutController {
|
|||
async logoutAsync() {
|
||||
const error = this.$transition$.params().error;
|
||||
const performApiLogout = this.$transition$.params().performApiLogout;
|
||||
const settings = await this.SettingsService.publicSettings();
|
||||
|
||||
try {
|
||||
await this.Authentication.logout(performApiLogout);
|
||||
} finally {
|
||||
this.LocalStorage.storeLogoutReason(error);
|
||||
this.$state.go('portainer.auth', { reload: true });
|
||||
if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) {
|
||||
this.$window.location.href = settings.OAuthLogoutURI;
|
||||
} else {
|
||||
this.$state.go('portainer.auth', { reload: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue