From e7bd64d7a3dc9768657c67e7bfa816a17a4bd92d Mon Sep 17 00:00:00 2001 From: Pushpinder Singh <53684951+pushpinderbal@users.noreply.github.com> Date: Wed, 7 Jan 2026 06:37:23 -0500 Subject: [PATCH] feat: add session max lifetime and fix refresh logic (#559) * feat: allow any HTTP method for /api/auth/envoy and restrict methods for non-envoy proxies * feat: add Allow header for invalid methods in proxyHandler * feat: add session max lifetime and fix refresh logic * fix: set default value for created_at column and improve session expiration logic --------- Co-authored-by: Stavros --- .env.example | 2 + cmd/tinyauth/tinyauth.go | 7 +-- config.example.yaml | 2 + .../migrations/000004_created_at.down.sql | 1 + .../migrations/000004_created_at.up.sql | 1 + internal/bootstrap/app_bootstrap.go | 4 ++ internal/bootstrap/db_bootstrap.go | 4 ++ internal/bootstrap/service_bootstrap.go | 17 ++++---- internal/config/config.go | 13 +++--- internal/controller/proxy_controller_test.go | 15 ++++--- internal/controller/user_controller_test.go | 15 ++++--- internal/repository/models.go | 1 + internal/repository/query.sql.go | 14 ++++-- internal/service/auth_service.go | 43 ++++++++++++++----- query.sql | 3 +- schema.sql | 1 + 16 files changed, 96 insertions(+), 47 deletions(-) create mode 100644 internal/assets/migrations/000004_created_at.down.sql create mode 100644 internal/assets/migrations/000004_created_at.up.sql diff --git a/.env.example b/.env.example index 6bf3d45..607ce08 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,8 @@ TINYAUTH_AUTH_USERSFILE="" TINYAUTH_AUTH_SECURECOOKIE="true" # Session expiry in seconds (7200 = 2 hours) TINYAUTH_AUTH_SESSIONEXPIRY="7200" +# Session maximum lifetime in seconds (0 = unlimited) +TINYAUTH_AUTH_SESSIONMAXLIFETIME="0" # Login timeout in seconds (300 = 5 minutes) TINYAUTH_AUTH_LOGINTIMEOUT="300" # Maximum login retries before lockout diff --git a/cmd/tinyauth/tinyauth.go b/cmd/tinyauth/tinyauth.go index 33b4015..7d0dbf7 100644 --- a/cmd/tinyauth/tinyauth.go +++ b/cmd/tinyauth/tinyauth.go @@ -25,9 +25,10 @@ func NewTinyauthCmdConfiguration() *config.Config { Address: "0.0.0.0", }, Auth: config.AuthConfig{ - SessionExpiry: 3600, - LoginTimeout: 300, - LoginMaxRetries: 3, + SessionExpiry: 3600, + SessionMaxLifetime: 0, + LoginTimeout: 300, + LoginMaxRetries: 3, }, UI: config.UIConfig{ Title: "Tinyauth", diff --git a/config.example.yaml b/config.example.yaml index 544bc83..26e56d5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -38,6 +38,8 @@ auth: secureCookie: false # Session expiry in seconds (3600 = 1 hour) sessionExpiry: 3600 + # Session maximum lifetime in seconds (0 = unlimited) + sessionMaxLifetime: 0 # Login timeout in seconds (300 = 5 minutes) loginTimeout: 300 # Maximum login retries before lockout diff --git a/internal/assets/migrations/000004_created_at.down.sql b/internal/assets/migrations/000004_created_at.down.sql new file mode 100644 index 0000000..fa7d58a --- /dev/null +++ b/internal/assets/migrations/000004_created_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" DROP COLUMN "created_at"; diff --git a/internal/assets/migrations/000004_created_at.up.sql b/internal/assets/migrations/000004_created_at.up.sql new file mode 100644 index 0000000..a21e944 --- /dev/null +++ b/internal/assets/migrations/000004_created_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" ADD COLUMN "created_at" INTEGER NOT NULL DEFAULT 0; diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 5c45f9c..414beea 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -42,6 +42,10 @@ func NewBootstrapApp(config config.Config) *BootstrapApp { } func (app *BootstrapApp) Setup() error { + // validate session config + if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry { + return fmt.Errorf("session max lifetime cannot be less than session expiry") + } // Parse users users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile) diff --git a/internal/bootstrap/db_bootstrap.go b/internal/bootstrap/db_bootstrap.go index ad8f4f6..ab10daa 100644 --- a/internal/bootstrap/db_bootstrap.go +++ b/internal/bootstrap/db_bootstrap.go @@ -27,6 +27,10 @@ func (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) { return nil, fmt.Errorf("failed to open database: %w", err) } + // Limit to 1 connection to sequence writes, this may need to be revisited in the future + // if the sqlite connection starts being a bottleneck + db.SetMaxOpenConns(1) + migrations, err := iofs.New(assets.Migrations, "migrations") if err != nil { diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index b41fc62..6f6a088 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -58,14 +58,15 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er services.accessControlService = accessControlsService authService := service.NewAuthService(service.AuthServiceConfig{ - Users: app.context.users, - OauthWhitelist: app.config.OAuth.Whitelist, - SessionExpiry: app.config.Auth.SessionExpiry, - SecureCookie: app.config.Auth.SecureCookie, - CookieDomain: app.context.cookieDomain, - LoginTimeout: app.config.Auth.LoginTimeout, - LoginMaxRetries: app.config.Auth.LoginMaxRetries, - SessionCookieName: app.context.sessionCookieName, + Users: app.context.users, + OauthWhitelist: app.config.OAuth.Whitelist, + SessionExpiry: app.config.Auth.SessionExpiry, + SessionMaxLifetime: app.config.Auth.SessionMaxLifetime, + SecureCookie: app.config.Auth.SecureCookie, + CookieDomain: app.context.cookieDomain, + LoginTimeout: app.config.Auth.LoginTimeout, + LoginMaxRetries: app.config.Auth.LoginMaxRetries, + SessionCookieName: app.context.sessionCookieName, }, dockerService, ldapService, queries) err = authService.Init() diff --git a/internal/config/config.go b/internal/config/config.go index b7fe6e3..2e7af66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,12 +40,13 @@ type ServerConfig struct { } type AuthConfig struct { - Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` - UsersFile string `description:"Path to the users file." yaml:"usersFile"` - SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` - SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` - LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` - LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` + Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` + UsersFile string `description:"Path to the users file." yaml:"usersFile"` + SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` + SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` + SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` + LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` + LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` } type OAuthConfig struct { diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 4b6a7e4..5f44382 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -57,13 +57,14 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test }, }, - OauthWhitelist: "", - SessionExpiry: 3600, - SecureCookie: false, - CookieDomain: "localhost", - LoginTimeout: 300, - LoginMaxRetries: 3, - SessionCookieName: "tinyauth-session", + OauthWhitelist: "", + SessionExpiry: 3600, + SessionMaxLifetime: 0, + SecureCookie: false, + CookieDomain: "localhost", + LoginTimeout: 300, + LoginMaxRetries: 3, + SessionCookieName: "tinyauth-session", }, dockerService, nil, queries) // Controller diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index ff95a3c..dc8ec5c 100644 --- a/internal/controller/user_controller_test.go +++ b/internal/controller/user_controller_test.go @@ -60,13 +60,14 @@ func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Eng TotpSecret: totpSecret, }, }, - OauthWhitelist: "", - SessionExpiry: 3600, - SecureCookie: false, - CookieDomain: "localhost", - LoginTimeout: 300, - LoginMaxRetries: 3, - SessionCookieName: "tinyauth-session", + OauthWhitelist: "", + SessionExpiry: 3600, + SessionMaxLifetime: 0, + SecureCookie: false, + CookieDomain: "localhost", + LoginTimeout: 300, + LoginMaxRetries: 3, + SessionCookieName: "tinyauth-session", }, nil, nil, queries) // Controller diff --git a/internal/repository/models.go b/internal/repository/models.go index 0f5195e..61f7f80 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -13,6 +13,7 @@ type Session struct { TotpPending bool OAuthGroups string Expiry int64 + CreatedAt int64 OAuthName string OAuthSub string } diff --git a/internal/repository/query.sql.go b/internal/repository/query.sql.go index 110bd1b..5924842 100644 --- a/internal/repository/query.sql.go +++ b/internal/repository/query.sql.go @@ -19,12 +19,13 @@ INSERT INTO sessions ( "totp_pending", "oauth_groups", "expiry", + "created_at", "oauth_name", "oauth_sub" ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) -RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, oauth_name, oauth_sub +RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub ` type CreateSessionParams struct { @@ -36,6 +37,7 @@ type CreateSessionParams struct { TotpPending bool OAuthGroups string Expiry int64 + CreatedAt int64 OAuthName string OAuthSub string } @@ -50,6 +52,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S arg.TotpPending, arg.OAuthGroups, arg.Expiry, + arg.CreatedAt, arg.OAuthName, arg.OAuthSub, ) @@ -63,6 +66,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S &i.TotpPending, &i.OAuthGroups, &i.Expiry, + &i.CreatedAt, &i.OAuthName, &i.OAuthSub, ) @@ -90,7 +94,7 @@ func (q *Queries) DeleteSession(ctx context.Context, uuid string) error { } const getSession = `-- name: GetSession :one -SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, oauth_name, oauth_sub FROM "sessions" +SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM "sessions" WHERE "uuid" = ? ` @@ -106,6 +110,7 @@ func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) &i.TotpPending, &i.OAuthGroups, &i.Expiry, + &i.CreatedAt, &i.OAuthName, &i.OAuthSub, ) @@ -124,7 +129,7 @@ UPDATE "sessions" SET "oauth_name" = ?, "oauth_sub" = ? WHERE "uuid" = ? -RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, oauth_name, oauth_sub +RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub ` type UpdateSessionParams struct { @@ -163,6 +168,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S &i.TotpPending, &i.OAuthGroups, &i.Expiry, + &i.CreatedAt, &i.OAuthName, &i.OAuthSub, ) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index e823e2a..e71fd5f 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -26,14 +26,15 @@ type LoginAttempt struct { } type AuthServiceConfig struct { - Users []config.User - OauthWhitelist string - SessionExpiry int - SecureCookie bool - CookieDomain string - LoginTimeout int - LoginMaxRetries int - SessionCookieName string + Users []config.User + OauthWhitelist string + SessionExpiry int + SessionMaxLifetime int + SecureCookie bool + CookieDomain string + LoginTimeout int + LoginMaxRetries int + SessionCookieName string } type AuthService struct { @@ -212,6 +213,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio TotpPending: data.TotpPending, OAuthGroups: data.OAuthGroups, Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(), + CreatedAt: time.Now().Unix(), OAuthName: data.OAuthName, OAuthSub: data.OAuthSub, } @@ -242,11 +244,19 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error { currentTime := time.Now().Unix() - if session.Expiry-currentTime > int64(time.Hour.Seconds()) { + var refreshThreshold int64 + + if auth.config.SessionExpiry <= int(time.Hour.Seconds()) { + refreshThreshold = int64(auth.config.SessionExpiry / 2) + } else { + refreshThreshold = int64(time.Hour.Seconds()) + } + + if session.Expiry-currentTime > refreshThreshold { return nil } - newExpiry := currentTime + int64(time.Hour.Seconds()) + newExpiry := session.Expiry + refreshThreshold _, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{ Username: session.Username, @@ -265,7 +275,8 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error { return err } - c.SetCookie(auth.config.SessionCookieName, cookie, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) + c.SetCookie(auth.config.SessionCookieName, cookie, int(newExpiry-currentTime), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) + log.Trace().Str("username", session.Username).Msg("Session cookie refreshed") return nil } @@ -306,6 +317,16 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, currentTime := time.Now().Unix() + if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 { + if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) { + err = auth.queries.DeleteSession(c, cookie) + if err != nil { + log.Error().Err(err).Msg("Failed to delete session exceeding max lifetime") + } + return config.SessionCookie{}, fmt.Errorf("session expired due to max lifetime exceeded") + } + } + if currentTime > session.Expiry { err = auth.queries.DeleteSession(c, cookie) if err != nil { diff --git a/query.sql b/query.sql index 36d0e7f..9fde4e2 100644 --- a/query.sql +++ b/query.sql @@ -8,10 +8,11 @@ INSERT INTO sessions ( "totp_pending", "oauth_groups", "expiry", + "created_at", "oauth_name", "oauth_sub" ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) RETURNING *; diff --git a/schema.sql b/schema.sql index 4221930..a7f37eb 100644 --- a/schema.sql +++ b/schema.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS "sessions" ( "totp_pending" BOOLEAN NOT NULL, "oauth_groups" TEXT NULL, "expiry" INTEGER NOT NULL, + "created_at" INTEGER NOT NULL, "oauth_name" TEXT NULL, "oauth_sub" TEXT NULL );