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 <steveiliop56@gmail.com>
This commit is contained in:
Pushpinder Singh
2026-01-07 06:37:23 -05:00
committed by GitHub
parent 721f302c0b
commit e7bd64d7a3
16 changed files with 96 additions and 47 deletions

View File

@@ -38,6 +38,8 @@ TINYAUTH_AUTH_USERSFILE=""
TINYAUTH_AUTH_SECURECOOKIE="true" TINYAUTH_AUTH_SECURECOOKIE="true"
# Session expiry in seconds (7200 = 2 hours) # Session expiry in seconds (7200 = 2 hours)
TINYAUTH_AUTH_SESSIONEXPIRY="7200" TINYAUTH_AUTH_SESSIONEXPIRY="7200"
# Session maximum lifetime in seconds (0 = unlimited)
TINYAUTH_AUTH_SESSIONMAXLIFETIME="0"
# Login timeout in seconds (300 = 5 minutes) # Login timeout in seconds (300 = 5 minutes)
TINYAUTH_AUTH_LOGINTIMEOUT="300" TINYAUTH_AUTH_LOGINTIMEOUT="300"
# Maximum login retries before lockout # Maximum login retries before lockout

View File

@@ -25,9 +25,10 @@ func NewTinyauthCmdConfiguration() *config.Config {
Address: "0.0.0.0", Address: "0.0.0.0",
}, },
Auth: config.AuthConfig{ Auth: config.AuthConfig{
SessionExpiry: 3600, SessionExpiry: 3600,
LoginTimeout: 300, SessionMaxLifetime: 0,
LoginMaxRetries: 3, LoginTimeout: 300,
LoginMaxRetries: 3,
}, },
UI: config.UIConfig{ UI: config.UIConfig{
Title: "Tinyauth", Title: "Tinyauth",

View File

@@ -38,6 +38,8 @@ auth:
secureCookie: false secureCookie: false
# Session expiry in seconds (3600 = 1 hour) # Session expiry in seconds (3600 = 1 hour)
sessionExpiry: 3600 sessionExpiry: 3600
# Session maximum lifetime in seconds (0 = unlimited)
sessionMaxLifetime: 0
# Login timeout in seconds (300 = 5 minutes) # Login timeout in seconds (300 = 5 minutes)
loginTimeout: 300 loginTimeout: 300
# Maximum login retries before lockout # Maximum login retries before lockout

View File

@@ -0,0 +1 @@
ALTER TABLE "sessions" DROP COLUMN "created_at";

View File

@@ -0,0 +1 @@
ALTER TABLE "sessions" ADD COLUMN "created_at" INTEGER NOT NULL DEFAULT 0;

View File

@@ -42,6 +42,10 @@ func NewBootstrapApp(config config.Config) *BootstrapApp {
} }
func (app *BootstrapApp) Setup() error { 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 // Parse users
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile) users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)

View File

@@ -27,6 +27,10 @@ func (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) {
return nil, fmt.Errorf("failed to open database: %w", err) 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") migrations, err := iofs.New(assets.Migrations, "migrations")
if err != nil { if err != nil {

View File

@@ -58,14 +58,15 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.accessControlService = accessControlsService services.accessControlService = accessControlsService
authService := service.NewAuthService(service.AuthServiceConfig{ authService := service.NewAuthService(service.AuthServiceConfig{
Users: app.context.users, Users: app.context.users,
OauthWhitelist: app.config.OAuth.Whitelist, OauthWhitelist: app.config.OAuth.Whitelist,
SessionExpiry: app.config.Auth.SessionExpiry, SessionExpiry: app.config.Auth.SessionExpiry,
SecureCookie: app.config.Auth.SecureCookie, SessionMaxLifetime: app.config.Auth.SessionMaxLifetime,
CookieDomain: app.context.cookieDomain, SecureCookie: app.config.Auth.SecureCookie,
LoginTimeout: app.config.Auth.LoginTimeout, CookieDomain: app.context.cookieDomain,
LoginMaxRetries: app.config.Auth.LoginMaxRetries, LoginTimeout: app.config.Auth.LoginTimeout,
SessionCookieName: app.context.sessionCookieName, LoginMaxRetries: app.config.Auth.LoginMaxRetries,
SessionCookieName: app.context.sessionCookieName,
}, dockerService, ldapService, queries) }, dockerService, ldapService, queries)
err = authService.Init() err = authService.Init()

View File

@@ -40,12 +40,13 @@ type ServerConfig struct {
} }
type AuthConfig struct { type AuthConfig struct {
Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
UsersFile string `description:"Path to the users file." yaml:"usersFile"` UsersFile string `description:"Path to the users file." yaml:"usersFile"`
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
} }
type OAuthConfig struct { type OAuthConfig struct {

View File

@@ -57,13 +57,14 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test
}, },
}, },
OauthWhitelist: "", OauthWhitelist: "",
SessionExpiry: 3600, SessionExpiry: 3600,
SecureCookie: false, SessionMaxLifetime: 0,
CookieDomain: "localhost", SecureCookie: false,
LoginTimeout: 300, CookieDomain: "localhost",
LoginMaxRetries: 3, LoginTimeout: 300,
SessionCookieName: "tinyauth-session", LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}, dockerService, nil, queries) }, dockerService, nil, queries)
// Controller // Controller

View File

@@ -60,13 +60,14 @@ func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Eng
TotpSecret: totpSecret, TotpSecret: totpSecret,
}, },
}, },
OauthWhitelist: "", OauthWhitelist: "",
SessionExpiry: 3600, SessionExpiry: 3600,
SecureCookie: false, SessionMaxLifetime: 0,
CookieDomain: "localhost", SecureCookie: false,
LoginTimeout: 300, CookieDomain: "localhost",
LoginMaxRetries: 3, LoginTimeout: 300,
SessionCookieName: "tinyauth-session", LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}, nil, nil, queries) }, nil, nil, queries)
// Controller // Controller

View File

@@ -13,6 +13,7 @@ type Session struct {
TotpPending bool TotpPending bool
OAuthGroups string OAuthGroups string
Expiry int64 Expiry int64
CreatedAt int64
OAuthName string OAuthName string
OAuthSub string OAuthSub string
} }

View File

@@ -19,12 +19,13 @@ INSERT INTO sessions (
"totp_pending", "totp_pending",
"oauth_groups", "oauth_groups",
"expiry", "expiry",
"created_at",
"oauth_name", "oauth_name",
"oauth_sub" "oauth_sub"
) VALUES ( ) 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 { type CreateSessionParams struct {
@@ -36,6 +37,7 @@ type CreateSessionParams struct {
TotpPending bool TotpPending bool
OAuthGroups string OAuthGroups string
Expiry int64 Expiry int64
CreatedAt int64
OAuthName string OAuthName string
OAuthSub string OAuthSub string
} }
@@ -50,6 +52,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
arg.TotpPending, arg.TotpPending,
arg.OAuthGroups, arg.OAuthGroups,
arg.Expiry, arg.Expiry,
arg.CreatedAt,
arg.OAuthName, arg.OAuthName,
arg.OAuthSub, arg.OAuthSub,
) )
@@ -63,6 +66,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
&i.TotpPending, &i.TotpPending,
&i.OAuthGroups, &i.OAuthGroups,
&i.Expiry, &i.Expiry,
&i.CreatedAt,
&i.OAuthName, &i.OAuthName,
&i.OAuthSub, &i.OAuthSub,
) )
@@ -90,7 +94,7 @@ func (q *Queries) DeleteSession(ctx context.Context, uuid string) error {
} }
const getSession = `-- name: GetSession :one 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" = ? WHERE "uuid" = ?
` `
@@ -106,6 +110,7 @@ func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error)
&i.TotpPending, &i.TotpPending,
&i.OAuthGroups, &i.OAuthGroups,
&i.Expiry, &i.Expiry,
&i.CreatedAt,
&i.OAuthName, &i.OAuthName,
&i.OAuthSub, &i.OAuthSub,
) )
@@ -124,7 +129,7 @@ UPDATE "sessions" SET
"oauth_name" = ?, "oauth_name" = ?,
"oauth_sub" = ? "oauth_sub" = ?
WHERE "uuid" = ? 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 { type UpdateSessionParams struct {
@@ -163,6 +168,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
&i.TotpPending, &i.TotpPending,
&i.OAuthGroups, &i.OAuthGroups,
&i.Expiry, &i.Expiry,
&i.CreatedAt,
&i.OAuthName, &i.OAuthName,
&i.OAuthSub, &i.OAuthSub,
) )

View File

@@ -26,14 +26,15 @@ type LoginAttempt struct {
} }
type AuthServiceConfig struct { type AuthServiceConfig struct {
Users []config.User Users []config.User
OauthWhitelist string OauthWhitelist string
SessionExpiry int SessionExpiry int
SecureCookie bool SessionMaxLifetime int
CookieDomain string SecureCookie bool
LoginTimeout int CookieDomain string
LoginMaxRetries int LoginTimeout int
SessionCookieName string LoginMaxRetries int
SessionCookieName string
} }
type AuthService struct { type AuthService struct {
@@ -212,6 +213,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
TotpPending: data.TotpPending, TotpPending: data.TotpPending,
OAuthGroups: data.OAuthGroups, OAuthGroups: data.OAuthGroups,
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(), Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
CreatedAt: time.Now().Unix(),
OAuthName: data.OAuthName, OAuthName: data.OAuthName,
OAuthSub: data.OAuthSub, OAuthSub: data.OAuthSub,
} }
@@ -242,11 +244,19 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
currentTime := time.Now().Unix() 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 return nil
} }
newExpiry := currentTime + int64(time.Hour.Seconds()) newExpiry := session.Expiry + refreshThreshold
_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{ _, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{
Username: session.Username, Username: session.Username,
@@ -265,7 +275,8 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
return err 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 return nil
} }
@@ -306,6 +317,16 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
currentTime := time.Now().Unix() 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 { if currentTime > session.Expiry {
err = auth.queries.DeleteSession(c, cookie) err = auth.queries.DeleteSession(c, cookie)
if err != nil { if err != nil {

View File

@@ -8,10 +8,11 @@ INSERT INTO sessions (
"totp_pending", "totp_pending",
"oauth_groups", "oauth_groups",
"expiry", "expiry",
"created_at",
"oauth_name", "oauth_name",
"oauth_sub" "oauth_sub"
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
RETURNING *; RETURNING *;

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS "sessions" (
"totp_pending" BOOLEAN NOT NULL, "totp_pending" BOOLEAN NOT NULL,
"oauth_groups" TEXT NULL, "oauth_groups" TEXT NULL,
"expiry" INTEGER NOT NULL, "expiry" INTEGER NOT NULL,
"created_at" INTEGER NOT NULL,
"oauth_name" TEXT NULL, "oauth_name" TEXT NULL,
"oauth_sub" TEXT NULL "oauth_sub" TEXT NULL
); );