mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feat(auth): add custom user timeout (#3871)
* feat(auth): introduce new timeout constant * feat(auth): pass timeout from handler * feat(auth): add timeout selector to auth settings view * feat(settings): add user session timeout property * feat(auth): load user session timeout from settings * fix(settings): use correct time format * feat(auth): remove no-auth flag * refactor(auth): move timeout mgmt to jwt service * refactor(client): remove no-auth checks from client * refactor(cli): remove defaultNoAuth * feat(settings): create settings with default user timeout value * refactor(db): save user session timeout always * refactor(jwt): return error * feat(auth): set session timeout in jwt service on update * feat(auth): add description and time settings * feat(auth): parse duration * feat(settings): validate user timeout format * refactor(settings): remove unneccesary import
This commit is contained in:
parent
b58c2facfe
commit
b02749f877
73 changed files with 214 additions and 236 deletions
|
@ -27,6 +27,7 @@ func (store *Store) Init() error {
|
|||
EnableHostManagementFeatures: false,
|
||||
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
|
||||
TemplatesURL: portainer.DefaultTemplatesURL,
|
||||
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
|
||||
}
|
||||
|
||||
err = store.SettingsService.UpdateSettings(defaultSettings)
|
||||
|
|
|
@ -10,9 +10,9 @@ func (m *Migrator) updateSettingsToDB24() error {
|
|||
|
||||
if legacySettings.TemplatesURL == "" {
|
||||
legacySettings.TemplatesURL = portainer.DefaultTemplatesURL
|
||||
|
||||
return m.settingsService.UpdateSettings(legacySettings)
|
||||
}
|
||||
|
||||
return nil
|
||||
legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
|
||||
|
||||
return m.settingsService.UpdateSettings(legacySettings)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
|
@ -20,7 +19,6 @@ const (
|
|||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix://, npipe:// or tcp://")
|
||||
errSocketOrNamedPipeNotFound = portainer.Error("Unable to locate Unix socket or named pipe")
|
||||
errInvalidSnapshotInterval = portainer.Error("Invalid snapshot interval")
|
||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
||||
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
|
||||
)
|
||||
|
||||
|
@ -35,7 +33,6 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(),
|
||||
NoAuth: kingpin.Flag("no-auth", "Disable authentication (deprecated)").Default(defaultNoAuth).Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
|
||||
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
|
||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||
|
@ -81,10 +78,6 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
|
||||
return errNoAuthExcludeAdminPassword
|
||||
}
|
||||
|
||||
if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" {
|
||||
return errAdminPassExcludeAdminPassFile
|
||||
}
|
||||
|
@ -93,9 +86,7 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
|||
}
|
||||
|
||||
func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
||||
if *flags.NoAuth {
|
||||
log.Println("Warning: the --no-auth flag is deprecated and will likely be removed in a future version of Portainer.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func validateEndpointURL(endpointURL string) error {
|
||||
|
|
|
@ -8,7 +8,6 @@ const (
|
|||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
|
|
|
@ -6,7 +6,6 @@ const (
|
|||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
|
|
|
@ -77,15 +77,17 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer
|
|||
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||
if authenticationEnabled {
|
||||
jwtService, err := jwt.NewService()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return jwtService
|
||||
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
|
||||
settings, err := dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
|
||||
jwtService, err := jwt.NewService(settings.UserSessionTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jwtService, nil
|
||||
}
|
||||
|
||||
func initDigitalSignatureService() portainer.DigitalSignatureService {
|
||||
|
@ -188,9 +190,8 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p
|
|||
|
||||
func initStatus(flags *portainer.CLIFlags) *portainer.Status {
|
||||
return &portainer.Status{
|
||||
Analytics: !*flags.NoAnalytics,
|
||||
Authentication: !*flags.NoAuth,
|
||||
Version: portainer.APIVersion,
|
||||
Analytics: !*flags.NoAnalytics,
|
||||
Version: portainer.APIVersion,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -392,7 +393,10 @@ func main() {
|
|||
dataStore := initDataStore(*flags.Data, fileService)
|
||||
defer dataStore.Close()
|
||||
|
||||
jwtService := initJWTService(!*flags.NoAuth)
|
||||
jwtService, err := initJWTService(dataStore)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ldapService := initLDAPService()
|
||||
|
||||
|
@ -402,7 +406,7 @@ func main() {
|
|||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
err := initKeyPair(fileService, digitalSignatureService)
|
||||
err = initKeyPair(fileService, digitalSignatureService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
@ -492,9 +496,7 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
if !*flags.NoAuth {
|
||||
go terminateIfNoAdminCreated(dataStore)
|
||||
}
|
||||
go terminateIfNoAdminCreated(dataStore)
|
||||
|
||||
err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotter)
|
||||
if err != nil {
|
||||
|
@ -506,7 +508,6 @@ func main() {
|
|||
Status: applicationStatus,
|
||||
BindAddress: *flags.Addr,
|
||||
AssetsPath: *flags.Assets,
|
||||
AuthDisabled: *flags.NoAuth,
|
||||
DataStore: dataStore,
|
||||
SwarmStackManager: swarmStackManager,
|
||||
ComposeStackManager: composeStackManager,
|
||||
|
|
|
@ -32,10 +32,6 @@ func (payload *authenticatePayload) Validate(r *http.Request) error {
|
|||
}
|
||||
|
||||
func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
if handler.authDisabled {
|
||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled}
|
||||
}
|
||||
|
||||
var payload authenticatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
|
|
|
@ -10,16 +10,9 @@ import (
|
|||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrAuthDisabled is an error raised when trying to access the authentication endpoints
|
||||
// when the server has been started with the --no-auth flag
|
||||
ErrAuthDisabled = portainer.Error("Authentication is disabled")
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle authentication operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
authDisabled bool
|
||||
DataStore portainer.DataStore
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService portainer.JWTService
|
||||
|
@ -29,10 +22,9 @@ type Handler struct {
|
|||
}
|
||||
|
||||
// NewHandler creates a handler to manage authentication operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *Handler {
|
||||
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
authDisabled: authDisabled,
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
h.Handle("/auth/oauth/validate",
|
||||
|
|
|
@ -17,11 +17,12 @@ func hideFields(settings *portainer.Settings) {
|
|||
// Handler is the HTTP handler used to handle settings operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
AuthorizationService *portainer.AuthorizationService
|
||||
DataStore portainer.DataStore
|
||||
LDAPService portainer.LDAPService
|
||||
FileService portainer.FileService
|
||||
JobScheduler portainer.JobScheduler
|
||||
AuthorizationService *portainer.AuthorizationService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage settings operations.
|
||||
|
|
|
@ -2,6 +2,7 @@ package settings
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
|
@ -25,6 +26,7 @@ type settingsUpdatePayload struct {
|
|||
TemplatesURL *string
|
||||
EdgeAgentCheckinInterval *int
|
||||
EnableEdgeComputeFeatures *bool
|
||||
UserSessionTimeout *string
|
||||
}
|
||||
|
||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
|
@ -37,6 +39,13 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
|||
if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) {
|
||||
return portainer.Error("Invalid external templates URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.UserSessionTimeout != nil {
|
||||
_, err := time.ParseDuration(*payload.UserSessionTimeout)
|
||||
if err != nil {
|
||||
return portainer.Error("Invalid user session timeout")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -125,6 +134,14 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
if payload.UserSessionTimeout != nil {
|
||||
settings.UserSessionTimeout = *payload.UserSessionTimeout
|
||||
|
||||
userSessionDuration, _ := time.ParseDuration(*payload.UserSessionTimeout)
|
||||
|
||||
handler.JWTService.SetUserSessionDuration(userSessionDuration)
|
||||
}
|
||||
|
||||
tlsError := handler.updateTLS(settings)
|
||||
if tlsError != nil {
|
||||
return tlsError
|
||||
|
|
|
@ -16,7 +16,6 @@ type (
|
|||
dataStore portainer.DataStore
|
||||
jwtService portainer.JWTService
|
||||
rbacExtensionClient *rbacExtensionClient
|
||||
authDisabled bool
|
||||
}
|
||||
|
||||
// RestrictedRequestContext is a data structure containing information
|
||||
|
@ -30,12 +29,11 @@ type (
|
|||
)
|
||||
|
||||
// NewRequestBouncer initializes a new RequestBouncer
|
||||
func NewRequestBouncer(dataStore portainer.DataStore, jwtService portainer.JWTService, authenticationDisabled bool, rbacExtensionURL string) *RequestBouncer {
|
||||
func NewRequestBouncer(dataStore portainer.DataStore, jwtService portainer.JWTService, rbacExtensionURL string) *RequestBouncer {
|
||||
return &RequestBouncer{
|
||||
dataStore: dataStore,
|
||||
jwtService: jwtService,
|
||||
rbacExtensionClient: newRBACExtensionClient(rbacExtensionURL),
|
||||
authDisabled: authenticationDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -289,44 +287,38 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
|
|||
func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var tokenData *portainer.TokenData
|
||||
if !bouncer.authDisabled {
|
||||
var token string
|
||||
var token string
|
||||
|
||||
// Optionally, token might be set via the "token" query parameter.
|
||||
// For example, in websocket requests
|
||||
token = r.URL.Query().Get("token")
|
||||
// Optionally, token might be set via the "token" query parameter.
|
||||
// For example, in websocket requests
|
||||
token = r.URL.Query().Get("token")
|
||||
|
||||
// Get token from the Authorization header
|
||||
tokens, ok := r.Header["Authorization"]
|
||||
if ok && len(tokens) >= 1 {
|
||||
token = tokens[0]
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
// Get token from the Authorization header
|
||||
tokens, ok := r.Header["Authorization"]
|
||||
if ok && len(tokens) >= 1 {
|
||||
token = tokens[0]
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = bouncer.dataStore.User().User(tokenData.ID)
|
||||
if err != nil && err == portainer.ErrObjectNotFound {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
tokenData = &portainer.TokenData{
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
_, err = bouncer.dataStore.User().User(tokenData.ID)
|
||||
if err != nil && err == portainer.ErrObjectNotFound {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := storeTokenData(r, tokenData)
|
||||
|
|
|
@ -47,7 +47,6 @@ import (
|
|||
type Server struct {
|
||||
BindAddress string
|
||||
AssetsPath string
|
||||
AuthDisabled bool
|
||||
Status *portainer.Status
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
ExtensionManager portainer.ExtensionManager
|
||||
|
@ -77,11 +76,11 @@ func (server *Server) Start() error {
|
|||
authorizationService := portainer.NewAuthorizationService(server.DataStore)
|
||||
|
||||
rbacExtensionURL := proxyManager.GetExtensionURL(portainer.RBACExtension)
|
||||
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.AuthDisabled, rbacExtensionURL)
|
||||
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, rbacExtensionURL)
|
||||
|
||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||
|
||||
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled)
|
||||
var authHandler = auth.NewHandler(requestBouncer, rateLimiter)
|
||||
authHandler.DataStore = server.DataStore
|
||||
authHandler.CryptoService = server.CryptoService
|
||||
authHandler.JWTService = server.JWTService
|
||||
|
@ -153,11 +152,12 @@ func (server *Server) Start() error {
|
|||
schedulesHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
var settingsHandler = settings.NewHandler(requestBouncer)
|
||||
settingsHandler.AuthorizationService = authorizationService
|
||||
settingsHandler.DataStore = server.DataStore
|
||||
settingsHandler.LDAPService = server.LDAPService
|
||||
settingsHandler.FileService = server.FileService
|
||||
settingsHandler.JobScheduler = server.JobScheduler
|
||||
settingsHandler.AuthorizationService = authorizationService
|
||||
settingsHandler.JWTService = server.JWTService
|
||||
settingsHandler.LDAPService = server.LDAPService
|
||||
|
||||
var stackHandler = stacks.NewHandler(requestBouncer)
|
||||
stackHandler.DataStore = server.DataStore
|
||||
|
|
|
@ -12,7 +12,8 @@ import (
|
|||
|
||||
// Service represents a service for managing JWT tokens.
|
||||
type Service struct {
|
||||
secret []byte
|
||||
secret []byte
|
||||
userSessionTimeout time.Duration
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
|
@ -23,20 +24,27 @@ type claims struct {
|
|||
}
|
||||
|
||||
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
|
||||
func NewService() (*Service, error) {
|
||||
func NewService(userSessionDuration string) (*Service, error) {
|
||||
userSessionTimeout, err := time.ParseDuration(userSessionDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret := securecookie.GenerateRandomKey(32)
|
||||
if secret == nil {
|
||||
return nil, portainer.ErrSecretGeneration
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
secret,
|
||||
userSessionTimeout,
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GenerateToken generates a new JWT token.
|
||||
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
||||
expireToken := time.Now().Add(time.Hour * 8).Unix()
|
||||
expireToken := time.Now().Add(service.userSessionTimeout).Unix()
|
||||
cl := claims{
|
||||
UserID: int(data.ID),
|
||||
Username: data.Username,
|
||||
|
@ -77,3 +85,8 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
|
|||
|
||||
return nil, portainer.ErrInvalidJWTToken
|
||||
}
|
||||
|
||||
// SetUserSessionDuration sets the user session duration
|
||||
func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) {
|
||||
service.userSessionTimeout = userSessionDuration
|
||||
}
|
||||
|
|
|
@ -46,7 +46,6 @@ type (
|
|||
EndpointURL *string
|
||||
Labels *[]Pair
|
||||
Logo *string
|
||||
NoAuth *bool
|
||||
NoAnalytics *bool
|
||||
Templates *string
|
||||
TLS *bool
|
||||
|
@ -459,6 +458,7 @@ type (
|
|||
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
|
||||
EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
|
||||
UserSessionTimeout string `json:"UserSessionTimeout"`
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
|
@ -517,9 +517,8 @@ type (
|
|||
|
||||
// Status represents the application status
|
||||
Status struct {
|
||||
Authentication bool `json:"Authentication"`
|
||||
Analytics bool `json:"Analytics"`
|
||||
Version string `json:"Version"`
|
||||
Analytics bool `json:"Analytics"`
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
|
||||
// Tag represents a tag that can be associated to a resource
|
||||
|
@ -842,6 +841,7 @@ type (
|
|||
JWTService interface {
|
||||
GenerateToken(data *TokenData) (string, error)
|
||||
ParseAndVerifyToken(token string) (*TokenData, error)
|
||||
SetUserSessionDuration(userSessionDuration time.Duration)
|
||||
}
|
||||
|
||||
// LDAPService represents a service used to authenticate users against a LDAP/AD
|
||||
|
@ -1057,6 +1057,8 @@ const (
|
|||
LocalExtensionManifestFile = "/extensions.json"
|
||||
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
|
||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json"
|
||||
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
||||
DefaultUserSessionTimeout = "8h"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue