Compare commits

..
Author SHA1 Message Date
Stavros 5c2cb08a7a fix: use constant time in user lookups 2026-07-14 15:14:26 +03:00
23 changed files with 242 additions and 267 deletions
+2
View File
@@ -101,6 +101,8 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0
TINYAUTH_AUTH_LOGINTIMEOUT=300 TINYAUTH_AUTH_LOGINTIMEOUT=300
# Maximum login retries. # Maximum login retries.
TINYAUTH_AUTH_LOGINMAXRETRIES=3 TINYAUTH_AUTH_LOGINMAXRETRIES=3
# Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically.
TINYAUTH_AUTH_LOCKDOWNENABLED=true
# Comma-separated list of trusted proxy addresses. # Comma-separated list of trusted proxy addresses.
TINYAUTH_AUTH_TRUSTEDPROXIES= TINYAUTH_AUTH_TRUSTEDPROXIES=
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow. # ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
REPO: ${{ github.event.repository.name }} REPO: ${{ github.event.repository.name }}
- name: Create release - name: Create release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with: with:
prerelease: true prerelease: true
tag_name: nightly tag_name: nightly
@@ -476,7 +476,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with: with:
files: binaries/* files: binaries/*
tag_name: nightly tag_name: nightly
+1 -1
View File
@@ -449,6 +449,6 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with: with:
files: binaries/* files: binaries/*
+11 -11
View File
@@ -62,19 +62,18 @@ If you like, you can help translate Tinyauth into more languages by visiting the
Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file. Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file.
## Hosting Partners
If you use one of our partners, you can help support us while getting a great hosting deal.
<div>
<a title="InstaPods" target="_blank" href="https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth"><img src="https://instapods.com/deploy-button.svg"></a>
</div>
## Sponsors ## Sponsors
A big thank-you to the following individuals and companies A big thank you to the following people for providing me with more coffee:
for their support for Tinyauth.
### Companies and Organizations
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
- [CodeRabbit AI](https://www.coderabbit.ai)
- [Dosu](https://dosu.dev/)
- [InstaPods](https://instapods.com) - [Deploy Tinyauth from 3$/month](https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth)
### Individuals
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors --> <!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors -->
@@ -82,6 +81,7 @@ for their support for Tinyauth.
- **Freepik** for providing the police hat and badge. - **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo. - **Renee French** for the original gopher logo.
- **Coderabbit AI** for providing free AI code reviews.
- **Syrhu** for providing the background image of the app. - **Syrhu** for providing the background image of the app.
## Star History ## Star History
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

-10
View File
@@ -30,16 +30,6 @@ func (app *BootstrapApp) setupRouter() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set trusted proxies: %w", err) return fmt.Errorf("failed to set trusted proxies: %w", err)
} }
app.runtime.TrustedProxiesConfigured = true
} else {
err := engine.SetTrustedProxies(nil)
if err != nil {
return fmt.Errorf("failed to set trusted proxies: %w", err)
}
app.log.App.Warn().Msg("Trusted proxies are not configured, IP access controls will NOT work")
} }
middlewareProvideFor := []any{ middlewareProvideFor := []any{
+29 -34
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -344,31 +343,27 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
// Create the authorization code // Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext) code := controller.oidc.CreateCode(*authorizeReq, *userContext)
cu, err := url.Parse(authorizeReq.RedirectURI) queries, err := query.Values(AuthorizeCallback{
Code: code,
State: authorizeReq.State,
})
if err != nil { if err != nil {
controller.authorizeError(c, authorizeErrorParams{ controller.authorizeError(c, authorizeErrorParams{
err: err, err: err,
reason: "Failed to parse redirect URI", reason: "Failed to build query",
reasonPublic: "Failed to parse redirect URI", reasonPublic: "Failed to build query",
json: true, callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
}) })
return return
} }
q := cu.Query()
q.Set("code", code)
if authorizeReq.State != "" {
q.Set("state", authorizeReq.State)
}
cu.RawQuery = q.Encode()
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": cu.String(), "redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
}) })
} }
@@ -644,37 +639,37 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error") controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
if params.callback != "" { if params.callback != "" {
cu, err := url.Parse(params.callback) errorQueries := CallbackError{
Error: params.callbackError,
}
if params.reasonPublic != "" {
errorQueries.ErrorDescription = params.reasonPublic
}
if params.state != "" {
errorQueries.State = params.state
}
queries, err := query.Values(errorQueries)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse callback URL") controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
q := cu.Query() redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
q.Set("error", params.callbackError)
if params.reasonPublic != "" {
q.Set("error_description", params.reasonPublic)
}
if params.state != "" {
q.Set("state", params.state)
}
cu.RawQuery = q.Encode()
if params.json { if params.json {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": cu.String(), "redirect_uri": redirectUrl,
}) })
return return
} }
c.Redirect(http.StatusFound, cu.String()) c.Redirect(http.StatusFound, redirectUrl)
return return
} }
+3 -4
View File
@@ -111,10 +111,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP() clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{ aclsCtx := &service.ACLContext{
ACLs: acls, ACLs: acls,
IP: net.ParseIP(clientIP), IP: net.ParseIP(clientIP),
Path: proxyCtx.Path, Path: proxyCtx.Path,
TrustedProxiesConfigured: controller.runtime.TrustedProxiesConfigured,
} }
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) { if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
+1 -3
View File
@@ -708,7 +708,7 @@ func TestProxyController(t *testing.T) {
Log: log, Log: log,
}) })
authService, err := service.NewAuthService(service.AuthServiceInput{ authService := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -721,8 +721,6 @@ func TestProxyController(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
router := gin.Default() router := gin.Default()
+56 -24
View File
@@ -72,32 +72,11 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt") controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
controller.auth.DummyPasswordCheck(req.Password)
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
isLocked, remaining := controller.auth.IsAccountLocked(req.Username) isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
if isLocked { if isLocked {
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "account locked") controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "account locked")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true") c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
@@ -107,10 +86,44 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return return
} }
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
controller.constantTime(func() constantTimeRes {
if errors.Is(err, service.ErrUserNotFound) {
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
return constantTimeRes{
Code: 401,
Res: gin.H{
"status": 401,
"message": "Unauthorized",
},
}
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
return constantTimeRes{
Code: 500,
Res: gin.H{
"status": 500,
"message": "Internal Server Error",
},
}
}, func(res constantTimeRes) {
c.JSON(res.Code, res.Res)
}, time.Millisecond*45)
return
}
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil { if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "invalid password") if search.Type == model.UserLocal {
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
} else {
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
}
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -211,7 +224,11 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Info().Str("username", req.Username).Msg("Login successful") controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
controller.log.AuditLoginSuccess(req.Username, search.Type.String(), c.ClientIP()) if search.Type == model.UserLocal {
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
} else {
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
}
controller.auth.RecordLoginAttempt(req.Username, true) controller.auth.RecordLoginAttempt(req.Username, true)
@@ -458,3 +475,18 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
"message": "Login successful", "message": "Login successful",
}) })
} }
type constantTimeRes struct {
Code int
Res any
}
func (controller *UserController) constantTime(f func() constantTimeRes, rf func(res constantTimeRes), targetTime time.Duration) {
tStart := time.Now()
res := f()
tEnd := time.Now()
if tEnd.Sub(tStart) < targetTime {
time.Sleep(targetTime - tEnd.Sub(tStart))
}
rf(res)
}
+1 -4
View File
@@ -542,8 +542,7 @@ func TestUserController(t *testing.T) {
Runtime: &runtime, Runtime: &runtime,
Ctx: ctx, Ctx: ctx,
}) })
authService := service.NewAuthService(service.AuthServiceInput{
authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -556,8 +555,6 @@ func TestUserController(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
beforeEach := func() { beforeEach := func() {
// Clear failed login attempts before each test // Clear failed login attempts before each test
authService.ClearLoginAttempts() authService.ClearLoginAttempts()
@@ -2,7 +2,6 @@ package middleware
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -245,9 +244,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
search, err := m.auth.SearchUser(username) search, err := m.auth.SearchUser(username)
if err != nil { if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
m.auth.DummyPasswordCheck(password)
}
return nil, nil, fmt.Errorf("error searching for user: %w", err) return nil, nil, fmt.Errorf("error searching for user: %w", err)
} }
@@ -264,8 +264,7 @@ func TestContextMiddleware(t *testing.T) {
Runtime: &runtime, Runtime: &runtime,
Ctx: ctx, Ctx: ctx,
}) })
authService := service.NewAuthService(service.AuthServiceInput{
authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -278,8 +277,6 @@ func TestContextMiddleware(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{ contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
Log: log, Log: log,
RuntimeConfig: &runtime, RuntimeConfig: &runtime,
+2
View File
@@ -49,6 +49,7 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
ACLs: ACLsConfig{ ACLs: ACLsConfig{
Policy: "allow", Policy: "allow",
}, },
LockdownEnabled: true,
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -150,6 +151,7 @@ type AuthConfig struct {
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"` SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"`
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled,omitempty"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"` ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
} }
+11 -12
View File
@@ -1,18 +1,17 @@
package model package model
type RuntimeConfig struct { type RuntimeConfig struct {
AppURL string AppURL string
UUID string UUID string
CookieDomain string CookieDomain string
SessionCookieName string SessionCookieName string
CSRFCookieName string CSRFCookieName string
RedirectCookieName string RedirectCookieName string
OAuthSessionCookieName string OAuthSessionCookieName string
LocalUsers []LocalUser LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig OAuthProviders map[string]OAuthServiceConfig
OAuthWhitelist []string OAuthWhitelist []string
ConfiguredProviders []Provider ConfiguredProviders []Provider
TrustedProxiesConfigured bool
} }
type Provider struct { type Provider struct {
-10
View File
@@ -7,16 +7,6 @@ const (
UserLDAP UserLDAP
) )
func (t UserSearchType) String() string {
switch t {
case UserLocal:
return "local"
case UserLDAP:
return "ldap"
}
return "unknown"
}
type LDAPUser struct { type LDAPUser struct {
DN string DN string
Groups []string Groups []string
@@ -215,10 +215,6 @@ type IPAllowedRule struct {
} }
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectAllow // We can't block the proxy
}
// merge global and per-app block/allow lists // merge global and per-app block/allow lists
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...) blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...) allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
@@ -267,10 +263,6 @@ type IPBypassedRule struct {
} }
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectDeny
}
// merge global and per-app bypass lists // merge global and per-app bypass lists
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...) bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
if ctx.ACLs != nil { if ctx.ACLs != nil {
+26 -62
View File
@@ -611,20 +611,11 @@ func TestIPAllowedRule(t *testing.T) {
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{
name: "when trusted proxies are not configured, IP is allowed",
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
{ {
name: "allows when ACLs are nil and no global lists configured", name: "allows when ACLs are nil and no global lists configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -634,8 +625,7 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Block: []string{"10.0.0.1"}}, IP: model.AppIP{Block: []string{"10.0.0.1"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -647,9 +637,8 @@ func TestIPAllowedRule(t *testing.T) {
}, },
}, },
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -659,8 +648,7 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}}, IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
}, },
IP: net.ParseIP("192.168.1.10"), IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -672,9 +660,8 @@ func TestIPAllowedRule(t *testing.T) {
}, },
}, },
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("192.168.1.10"), IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -684,17 +671,15 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}}, IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
{ {
name: "allows when no block or allow lists are configured", name: "allows when no block or allow lists are configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -707,8 +692,7 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"}, Allow: []string{"10.0.0.1"},
}, },
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -721,8 +705,7 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"}, Allow: []string{"10.0.0.1"},
}, },
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -752,23 +735,12 @@ func TestIPBypassedRule(t *testing.T) {
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{
name: "when trusted proxies are not configured, IP is not bypassed",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: false,
},
expected: EffectDeny,
},
{ {
name: "deny when ACLs are nil and no global bypass", name: "deny when ACLs are nil and no global bypass",
rule: defaultIPBR, rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -776,9 +748,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "allows when ACLs are nil but IP matches global bypass", name: "allows when ACLs are nil but IP matches global bypass",
rule: globBypassIPBR, rule: globBypassIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -786,9 +757,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when ACLs are nil and IP does not match global bypass", name: "denies when ACLs are nil and IP does not match global bypass",
rule: globBypassIPBR, rule: globBypassIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("192.168.1.1"), IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -799,8 +769,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -811,8 +780,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}}, IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -823,8 +791,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -835,8 +802,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("192.168.1.1"), IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -844,9 +810,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when bypass list is empty", name: "denies when bypass list is empty",
rule: defaultIPBR, rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -857,8 +822,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}}, IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
+89 -34
View File
@@ -8,6 +8,7 @@ import (
"math/big" "math/big"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
@@ -68,13 +69,21 @@ type AuthService struct {
tailscale *TailscaleService tailscale *TailscaleService
policyEngine *PolicyEngine policyEngine *PolicyEngine
dummyHash string lockdown struct {
active bool
until time.Time
ctx context.Context
cancelFunc context.CancelFunc
mu sync.RWMutex
}
caches struct { caches struct {
login *CacheStore[LoginAttempt] login *CacheStore[LoginAttempt]
oauth *CacheStore[OAuthPendingSession] oauth *CacheStore[OAuthPendingSession]
ldap *CacheStore[[]string] ldap *CacheStore[[]string]
} }
maxLoginLimits int
} }
type AuthServiceInput struct { type AuthServiceInput struct {
@@ -92,7 +101,7 @@ type AuthServiceInput struct {
PolicyEngine *PolicyEngine PolicyEngine *PolicyEngine
} }
func NewAuthService(i AuthServiceInput) (*AuthService, error) { func NewAuthService(i AuthServiceInput) *AuthService {
service := &AuthService{ service := &AuthService{
log: i.Log, log: i.Log,
runtime: i.Runtime, runtime: i.Runtime,
@@ -105,18 +114,18 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) {
policyEngine: i.PolicyEngine, policyEngine: i.PolicyEngine,
} }
// dummy hash // get the max login limits based on the number of users and the configured max retries
dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost) service.maxLoginLimits = service.calculateLockdownLimit()
if err != nil { loginCacheSize := 0
return nil, fmt.Errorf("failed to generate dummy hash: %w", err)
if !service.config.Auth.LockdownEnabled {
loginCacheSize = service.maxLoginLimits
} }
service.dummyHash = string(dummyHash)
// caches setup // caches setup
oauthCache := NewCacheStore[OAuthPendingSession](256) oauthCache := NewCacheStore[OAuthPendingSession](256)
loginCache := NewCacheStore[LoginAttempt](service.calculateLockdownLimit()) loginCache := NewCacheStore[LoginAttempt](loginCacheSize)
ldapCache := NewCacheStore[[]string](1024) ldapCache := NewCacheStore[[]string](1024)
service.caches.oauth = oauthCache service.caches.oauth = oauthCache
@@ -139,28 +148,7 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) {
} }
}, ding.RingMinor) }, ding.RingMinor)
i.Ding.Go(func(ctx context.Context) { return service
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
service.log.App.Debug().Msg("Updating login cache limits")
service.caches.login.SetMaxSize(service.calculateLockdownLimit())
service.log.App.Debug().Msg("Login cache limits updated")
case <-ctx.Done():
return
}
}
}, ding.RingMinor)
return service, nil
}
func (auth *AuthService) DummyPasswordCheck(password string) {
bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password))
} }
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) { func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
@@ -257,6 +245,10 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
} }
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
if locked, remaining := auth.IsInLockdown(); locked {
return true, remaining
}
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 { if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
return false, 0 return false, 0
} }
@@ -279,6 +271,14 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
return return
} }
if !success && auth.config.Auth.LockdownEnabled && auth.caches.login.Size() >= auth.maxLoginLimits {
if locked, _ := auth.IsInLockdown(); locked {
return
}
go auth.lockdownMode()
return
}
auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) { auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) {
entry, ok := actions.Get(identifier) entry, ok := actions.Get(identifier)
@@ -344,7 +344,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user") return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
} }
u, err := uuid.NewRandom() uuid, err := uuid.NewRandom()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate session uuid: %w", err) return nil, fmt.Errorf("failed to generate session uuid: %w", err)
@@ -361,7 +361,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second) expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
session := repository.CreateSessionParams{ session := repository.CreateSessionParams{
UUID: u.String(), UUID: uuid.String(),
Username: data.Username, Username: data.Username,
Email: data.Email, Email: data.Email,
Name: data.Name, Name: data.Name,
@@ -616,7 +616,62 @@ func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPending
return &session, nil return &session, nil
} }
// ClearLoginAttempts is a testing function, not useful for anything else func (auth *AuthService) lockdownMode() {
auth.lockdown.mu.Lock()
if auth.lockdown.active {
auth.lockdown.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(auth.ctx)
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
auth.lockdown.active = true
auth.lockdown.ctx = ctx
auth.lockdown.cancelFunc = cancel
d := time.Duration(auth.config.Auth.LoginTimeout) * time.Second
auth.lockdown.until = time.Now().Add(d)
timer := time.NewTimer(d)
auth.lockdown.mu.Unlock()
defer cancel()
defer timer.Stop()
select {
case <-timer.C:
// Timer expired, end lockdown
case <-ctx.Done():
// Context cancelled, end lockdown
}
auth.lockdown.mu.Lock()
auth.log.App.Info().Msg("Exiting lockdown mode")
auth.caches.login.Clear()
auth.lockdown.active = false
auth.lockdown.until = time.Time{}
auth.lockdown.ctx = nil
auth.lockdown.cancelFunc = nil
auth.lockdown.mu.Unlock()
}
func (auth *AuthService) IsInLockdown() (bool, int) {
auth.lockdown.mu.RLock()
defer auth.lockdown.mu.RUnlock()
if auth.lockdown.active {
remaining := int(time.Until(auth.lockdown.until).Seconds())
return true, remaining
}
return false, 0
}
// mostly a testing function, not useful for anything else
func (auth *AuthService) ClearLoginAttempts() { func (auth *AuthService) ClearLoginAttempts() {
auth.caches.login.Clear() auth.caches.login.Clear()
} }
-16
View File
@@ -195,19 +195,3 @@ func (cs *CacheStore[T]) Clear() {
cs.cache = make(map[string]cacheEntry[T]) cs.cache = make(map[string]cacheEntry[T])
cs.order = make([]string, 0) cs.order = make([]string, 0)
} }
func (cs *CacheStore[T]) SetMaxSize(maxSize int) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.maxSize = maxSize
for len(cs.cache) > maxSize {
if !cs.evictOne() {
break
}
}
}
func (cs *CacheStore[T]) GetMaxSize() int {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.maxSize
}
-15
View File
@@ -316,21 +316,6 @@ func TestCacheStoreSizeAndClear(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
} }
func TestCacheStoreWithMaxSize(t *testing.T) {
cs := NewCacheStore[string](0)
assert.Equal(t, 0, cs.Size())
for i := 0; i < 100; i++ {
cs.Set(strconv.Itoa(i), strconv.Itoa(i), 0)
}
assert.Equal(t, 100, cs.Size())
cs.SetMaxSize(10)
assert.Equal(t, 10, cs.Size())
}
func TestCacheStoreWithLock(t *testing.T) { func TestCacheStoreWithLock(t *testing.T) {
cs := NewCacheStore[int](0) cs := NewCacheStore[int](0)
cs.Set("counter", 1, 0) cs.Set("counter", 1, 0)
+4 -5
View File
@@ -29,11 +29,10 @@ type Rule interface {
} }
type ACLContext struct { type ACLContext struct {
ACLs *model.App ACLs *model.App
UserContext *model.UserContext UserContext *model.UserContext
IP net.IP IP net.IP
Path string Path string
TrustedProxiesConfigured bool
} }
type PolicyEngine struct { type PolicyEngine struct {
+3 -4
View File
@@ -163,10 +163,9 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
}, },
}, },
}, },
CookieDomain: "example.com", CookieDomain: "example.com",
AppURL: "https://tinyauth.example.com", AppURL: "https://tinyauth.example.com",
SessionCookieName: "tinyauth-session", SessionCookieName: "tinyauth-session",
TrustedProxiesConfigured: true,
} }
return config, runtime return config, runtime