Compare commits

..

8 Commits

Author SHA1 Message Date
Stavros 5aeb886523 Merge branch 'main' into single-cookie-domain 2026-05-07 15:50:55 +03:00
Stavros 1382ab41e7 refactor: rework user context handling throughout tinyauth (#829)
* wip

* fix: fix util imports

* fix: fix bootstrap import issues

* fix: fix cli imports

* fix: context controller

* fix: use new context in user controller

* fix: fix imports and context in proxy controller

* fix: fix oauth and oidc controller imports and context

* feat: finalize context functionality

* refactor: simplify acls checking logic by passing the entire acl struct

* chore: rename get basic auth to encode basic auth for clarity

* fix: fix controller tests

* tests: fix service tests

* tests: fix utils tests

* tests: move to testify for testing in utils

* fix: fix config reference generator

* tests: add tests for context parsing

* tests: add tests for context middleware

* tests: remove error wrapper from context tests

* tests: fix log wrapper tests

* fix: fix verion setting in cd and dockerfiles

* fix: review comments batch 1

* fix: review comments batch 2

* fix: review comments batch 3

* fix: delete totp pending session cookie on totp success

* tests: fix user controller tests

* fix: don't audit login too early

* fix: own comments
2026-05-07 15:41:07 +03:00
dependabot[bot] 24f2da4e58 chore(deps): bump github/codeql-action from 4.35.2 to 4.35.3 (#837)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.2 to 4.35.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...e46ed2cbd01164d986452f91f178727624ae40d7)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 18:49:37 +03:00
Stavros b06b60150f Merge branch 'main' into single-cookie-domain 2026-05-04 15:59:22 +03:00
Stavros 4077bacfdf chore: rabbit feedback 2026-04-29 16:00:59 +03:00
Stavros 4c0181c5e2 Merge branch 'main' into single-cookie-domain 2026-04-29 15:50:52 +03:00
Stavros 44a7cbf41b fix: inform services and controllers if subdomain cookie domain is enabled 2026-04-29 15:49:45 +03:00
Jacek Kowalski d90e3d652d Add TINYAUTH_AUTH_SUBDOMAINSENABLED option
Setting it to false allows to use Tinyauth on top-level domain only,
but forbids automatic cross-app authentication using Traefik/Nginx.
2026-04-19 22:17:10 +02:00
22 changed files with 356 additions and 151 deletions
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to code-scanning - name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+9 -3
View File
@@ -29,7 +29,7 @@ type BootstrapApp struct {
csrfCookieName string csrfCookieName string
redirectCookieName string redirectCookieName string
oauthSessionCookieName string oauthSessionCookieName string
localUsers []model.LocalUser localUsers *[]model.LocalUser
oauthProviders map[string]model.OAuthServiceConfig oauthProviders map[string]model.OAuthServiceConfig
configuredProviders []controller.Provider configuredProviders []controller.Provider
oidcClients []model.OIDCClientConfig oidcClients []model.OIDCClientConfig
@@ -69,7 +69,7 @@ func (app *BootstrapApp) Setup() error {
return err return err
} }
app.context.localUsers = *users app.context.localUsers = users
// Setup OAuth providers // Setup OAuth providers
app.context.oauthProviders = app.config.OAuth.Providers app.context.oauthProviders = app.config.OAuth.Providers
@@ -104,7 +104,13 @@ func (app *BootstrapApp) Setup() error {
} }
// Get cookie domain // Get cookie domain
cookieDomain, err := utils.GetCookieDomain(app.context.appUrl) cookieDomainResolver := utils.GetCookieDomain
if !app.config.Auth.SubdomainsEnabled {
tlog.App.Info().Msg("Subdomains disabled, automatic authentication for proxied apps will not work")
cookieDomainResolver = utils.GetStandaloneCookieDomain
}
cookieDomain, err := cookieDomainResolver(app.context.appUrl)
if err != nil { if err != nil {
return err return err
+1
View File
@@ -84,6 +84,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
RedirectCookieName: app.context.redirectCookieName, RedirectCookieName: app.context.redirectCookieName,
CookieDomain: app.context.cookieDomain, CookieDomain: app.context.cookieDomain,
OAuthSessionCookieName: app.context.oauthSessionCookieName, OAuthSessionCookieName: app.context.oauthSessionCookieName,
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
}, apiRouter, app.services.authService) }, apiRouter, app.services.authService)
oauthController.SetupRoutes() oauthController.SetupRoutes()
+1
View File
@@ -100,6 +100,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
SessionCookieName: app.context.sessionCookieName, SessionCookieName: app.context.sessionCookieName,
IP: app.config.Auth.IP, IP: app.config.Auth.IP,
LDAPGroupsCacheTTL: app.config.LDAP.GroupCacheTTL, LDAPGroupsCacheTTL: app.config.LDAP.GroupCacheTTL,
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
}, services.ldapService, queries, services.oauthBrokerService) }, services.ldapService, queries, services.oauthBrokerService)
err = authService.Init() err = authService.Init()
+1 -1
View File
@@ -95,7 +95,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
Username: context.GetUsername(), Username: context.GetUsername(),
Name: context.GetName(), Name: context.GetName(),
Email: context.GetEmail(), Email: context.GetEmail(),
Provider: context.ProviderName(), Provider: context.GetProviderID(),
OAuth: context.IsOAuth(), OAuth: context.IsOAuth(),
TOTPPending: context.TOTPPending(), TOTPPending: context.TOTPPending(),
OAuthName: context.OAuthName(), OAuthName: context.OAuthName(),
+10 -2
View File
@@ -26,6 +26,7 @@ type OAuthControllerConfig struct {
SecureCookie bool SecureCookie bool
AppURL string AppURL string
CookieDomain string CookieDomain string
SubdomainsEnabled bool
} }
type OAuthController struct { type OAuthController struct {
@@ -105,7 +106,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
return return
} }
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -135,7 +136,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return return
} }
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie) oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
@@ -283,3 +284,10 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams)
params.ClientID != "" && params.ClientID != "" &&
params.RedirectURI != "" params.RedirectURI != ""
} }
func (controller *OAuthController) getCookieDomain() string {
if controller.config.SubdomainsEnabled {
return "." + controller.config.CookieDomain
}
return controller.config.CookieDomain
}
+1 -2
View File
@@ -21,7 +21,7 @@ func TestProxyController(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
authServiceCfg := service.AuthServiceConfig{ authServiceCfg := service.AuthServiceConfig{
LocalUsers: []model.LocalUser{ LocalUsers: &[]model.LocalUser{
{ {
Username: "testuser", Username: "testuser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
@@ -98,7 +98,6 @@ func TestProxyController(t *testing.T) {
Name: "Totpuser", Name: "Totpuser",
Email: "totpuser@example.com", Email: "totpuser@example.com",
}, },
TOTPEnabled: true,
}, },
}) })
c.Next() c.Next()
+45 -20
View File
@@ -102,7 +102,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil { if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
tlog.App.Warn().Str("username", req.Username).Msg("Invalid password") tlog.App.Warn().Err(err).Str("username", req.Username).Msg("Failed to verify password")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
tlog.AuditLoginFailure(c, req.Username, "username", "invalid password") tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
@@ -112,16 +112,20 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return return
} }
tlog.App.Info().Str("username", req.Username).Msg("Login successful")
tlog.AuditLoginSuccess(c, req.Username, "username")
controller.auth.RecordLoginAttempt(req.Username, true)
var localUser *model.LocalUser var localUser *model.LocalUser
if search.Type == model.UserLocal { if search.Type == model.UserLocal {
localUser = controller.auth.GetLocalUser(req.Username) localUser = controller.auth.GetLocalUser(req.Username)
if localUser == nil {
tlog.App.Warn().Str("username", req.Username).Msg("User disappeared during login")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
if localUser.TOTPSecret != "" { if localUser.TOTPSecret != "" {
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
@@ -198,6 +202,11 @@ func (controller *UserController) loginHandler(c *gin.Context) {
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
tlog.App.Info().Str("username", req.Username).Msg("Login successful")
tlog.AuditLoginSuccess(c, req.Username, "username")
controller.auth.RecordLoginAttempt(req.Username, true)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"message": "Login successful", "message": "Login successful",
@@ -226,17 +235,6 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
return return
} }
context, err := new(model.UserContext).NewFromGin(c)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get user context on logout")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
cookie, err := controller.auth.DeleteSession(c, uuid) cookie, err := controller.auth.DeleteSession(c, uuid)
if err != nil { if err != nil {
@@ -248,7 +246,14 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
return return
} }
tlog.AuditLogout(c, context.GetUsername(), context.ProviderName()) context, err := new(model.UserContext).NewFromGin(c)
if err == nil {
tlog.AuditLogout(c, context.GetUsername(), context.GetProviderID())
} else {
tlog.App.Warn().Err(err).Msg("Failed to get user context for logout audit, proceeding without username")
tlog.AuditLogout(c, "unknown", "unknown")
}
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
@@ -308,6 +313,15 @@ func (controller *UserController) totpHandler(c *gin.Context) {
user := controller.auth.GetLocalUser(context.GetUsername()) user := controller.auth.GetLocalUser(context.GetUsername())
if user == nil {
tlog.App.Error().Str("username", context.GetUsername()).Msg("User not found in TOTP handler")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
ok := totp.Validate(req.Code, user.TOTPSecret) ok := totp.Validate(req.Code, user.TOTPSecret)
if !ok { if !ok {
@@ -321,8 +335,16 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return return
} }
tlog.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful") uuid, err := c.Cookie(controller.config.SessionCookieName)
tlog.AuditLoginSuccess(c, context.GetUsername(), "totp")
if err == nil {
_, err = controller.auth.DeleteSession(c, uuid)
if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to delete pending TOTP session")
}
} else {
tlog.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, proceeding without deleting it")
}
controller.auth.RecordLoginAttempt(context.GetUsername(), true) controller.auth.RecordLoginAttempt(context.GetUsername(), true)
@@ -355,6 +377,9 @@ func (controller *UserController) totpHandler(c *gin.Context) {
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
tlog.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful")
tlog.AuditLoginSuccess(c, context.GetUsername(), "totp")
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"message": "Login successful", "message": "Login successful",
+59 -18
View File
@@ -1,7 +1,9 @@
package controller_test package controller_test
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http"
"net/http/httptest" "net/http/httptest"
"path" "path"
"strings" "strings"
@@ -25,7 +27,7 @@ func TestUserController(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
authServiceCfg := service.AuthServiceConfig{ authServiceCfg := service.AuthServiceConfig{
LocalUsers: []model.LocalUser{ LocalUsers: &[]model.LocalUser{
{ {
Username: "testuser", Username: "testuser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
@@ -67,7 +69,7 @@ func TestUserController(t *testing.T) {
totpCtx := func(c *gin.Context) { totpCtx := func(c *gin.Context) {
c.Set("context", &model.UserContext{ c.Set("context", &model.UserContext{
Authenticated: true, Authenticated: false,
Provider: model.ProviderLocal, Provider: model.ProviderLocal,
Local: &model.LocalContext{ Local: &model.LocalContext{
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
@@ -76,14 +78,13 @@ func TestUserController(t *testing.T) {
Email: "totpuser@example.com", Email: "totpuser@example.com",
}, },
TOTPPending: true, TOTPPending: true,
TOTPEnabled: true,
}, },
}) })
} }
totpAttrCtx := func(c *gin.Context) { totpAttrCtx := func(c *gin.Context) {
c.Set("context", &model.UserContext{ c.Set("context", &model.UserContext{
Authenticated: true, Authenticated: false,
Provider: model.ProviderLocal, Provider: model.ProviderLocal,
Local: &model.LocalContext{ Local: &model.LocalContext{
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
@@ -92,7 +93,6 @@ func TestUserController(t *testing.T) {
Email: "bob@example.com", Email: "bob@example.com",
}, },
TOTPPending: true, TOTPPending: true,
TOTPEnabled: true,
}, },
}) })
} }
@@ -111,6 +111,15 @@ func TestUserController(t *testing.T) {
}) })
} }
oauthBrokerCfgs := make(map[string]model.OAuthServiceConfig)
app := bootstrap.NewBootstrapApp(model.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
queries := repository.New(db)
type testCase struct { type testCase struct {
description string description string
middlewares []gin.HandlerFunc middlewares []gin.HandlerFunc
@@ -141,7 +150,9 @@ func TestUserController(t *testing.T) {
assert.Equal(t, "tinyauth-session", cookie.Name) assert.Equal(t, "tinyauth-session", cookie.Name)
assert.True(t, cookie.HttpOnly) assert.True(t, cookie.HttpOnly)
assert.Equal(t, "example.com", cookie.Domain) assert.Equal(t, "example.com", cookie.Domain)
assert.Equal(t, 10, cookie.MaxAge) // 3 seconds should be more than enough for even slow test environments
assert.GreaterOrEqual(t, cookie.MaxAge, 7)
assert.LessOrEqual(t, cookie.MaxAge, 10)
}, },
}, },
{ {
@@ -230,7 +241,8 @@ func TestUserController(t *testing.T) {
assert.Equal(t, "tinyauth-session", cookie.Name) assert.Equal(t, "tinyauth-session", cookie.Name)
assert.True(t, cookie.HttpOnly) assert.True(t, cookie.HttpOnly)
assert.Equal(t, "example.com", cookie.Domain) assert.Equal(t, "example.com", cookie.Domain)
assert.Equal(t, 3600, cookie.MaxAge) // 1 hour, default for totp pending sessions assert.GreaterOrEqual(t, cookie.MaxAge, 3597)
assert.LessOrEqual(t, cookie.MaxAge, 3600)
}, },
}, },
{ {
@@ -282,6 +294,18 @@ func TestUserController(t *testing.T) {
totpCtx, totpCtx,
}, },
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
UUID: "test-totp-login-uuid",
Username: "test",
Email: "test@example.com",
Name: "Test",
Provider: "local",
TotpPending: true,
Expiry: time.Now().Add(1 * time.Hour).Unix(),
CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now()) code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
assert.NoError(t, err) assert.NoError(t, err)
@@ -295,7 +319,13 @@ func TestUserController(t *testing.T) {
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody))) req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{
Name: "tinyauth-session",
Value: "test-totp-login-uuid",
HttpOnly: true,
MaxAge: 3600,
Expires: time.Now().Add(1 * time.Hour),
})
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 200, recorder.Code)
@@ -306,7 +336,8 @@ func TestUserController(t *testing.T) {
assert.Equal(t, "tinyauth-session", totpCookie.Name) assert.Equal(t, "tinyauth-session", totpCookie.Name)
assert.True(t, totpCookie.HttpOnly) assert.True(t, totpCookie.HttpOnly)
assert.Equal(t, "example.com", totpCookie.Domain) assert.Equal(t, "example.com", totpCookie.Domain)
assert.Equal(t, 10, totpCookie.MaxAge) // should use the regular session expiry time assert.GreaterOrEqual(t, totpCookie.MaxAge, 7)
assert.LessOrEqual(t, totpCookie.MaxAge, 10)
}, },
}, },
{ {
@@ -387,6 +418,18 @@ func TestUserController(t *testing.T) {
totpAttrCtx, totpAttrCtx,
}, },
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
UUID: "test-totp-login-attributes-uuid",
Username: "test",
Email: "test@example.com",
Name: "Test",
Provider: "local",
TotpPending: true,
Expiry: time.Now().Add(1 * time.Hour).Unix(),
CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now()) code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
require.NoError(t, err) require.NoError(t, err)
@@ -396,6 +439,13 @@ func TestUserController(t *testing.T) {
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body))) req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{
Name: "tinyauth-session",
Value: "test-totp-login-attributes-uuid",
HttpOnly: true,
MaxAge: 3600,
Expires: time.Now().Add(1 * time.Hour),
})
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
require.Equal(t, 200, recorder.Code) require.Equal(t, 200, recorder.Code)
@@ -406,15 +456,6 @@ func TestUserController(t *testing.T) {
}, },
} }
oauthBrokerCfgs := make(map[string]model.OAuthServiceConfig)
app := bootstrap.NewBootstrapApp(model.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
queries := repository.New(db)
docker := service.NewDockerService() docker := service.NewDockerService()
err = docker.Init() err = docker.Init()
require.NoError(t, err) require.NoError(t, err)
+9 -12
View File
@@ -70,20 +70,18 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
if err == nil { if err == nil {
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid) userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
if err != nil { if err == nil {
tlog.App.Error().Msgf("Error authenticating session cookie: %v", err) if cookie != nil {
http.SetCookie(c.Writer, cookie)
}
tlog.App.Trace().Msgf("Authenticated user from session cookie: %s", userContext.GetUsername())
c.Set("context", userContext)
c.Next() c.Next()
return return
} else {
tlog.App.Error().Msgf("Error authenticating session cookie: %v", err)
} }
if cookie != nil {
http.SetCookie(c.Writer, cookie)
}
tlog.App.Trace().Msgf("Authenticated user from session cookie: %s", userContext.GetUsername())
c.Set("context", userContext)
c.Next()
return
} }
username, password, ok := c.Request.BasicAuth() username, password, ok := c.Request.BasicAuth()
@@ -125,7 +123,6 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model
if userContext.Provider == model.ProviderLocal && if userContext.Provider == model.ProviderLocal &&
userContext.Local.TOTPPending { userContext.Local.TOTPPending {
userContext.Local.TOTPEnabled = true
return userContext, nil, nil return userContext, nil, nil
} }
+13 -3
View File
@@ -25,7 +25,7 @@ func TestContextMiddleware(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
authServiceCfg := service.AuthServiceConfig{ authServiceCfg := service.AuthServiceConfig{
LocalUsers: []model.LocalUser{ LocalUsers: &[]model.LocalUser{
{ {
Username: "testuser", Username: "testuser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
@@ -109,7 +109,6 @@ func TestContextMiddleware(t *testing.T) {
assert.Equal(t, "testuser", userCtx.GetUsername()) assert.Equal(t, "testuser", userCtx.GetUsername())
assert.True(t, userCtx.Authenticated) assert.True(t, userCtx.Authenticated)
require.NotNil(t, userCtx.Local) require.NotNil(t, userCtx.Local)
assert.False(t, userCtx.Local.TOTPEnabled)
}, },
}, },
{ {
@@ -134,7 +133,6 @@ func TestContextMiddleware(t *testing.T) {
assert.False(t, userCtx.Authenticated) assert.False(t, userCtx.Authenticated)
require.NotNil(t, userCtx.Local) require.NotNil(t, userCtx.Local)
assert.True(t, userCtx.Local.TOTPPending) assert.True(t, userCtx.Local.TOTPPending)
assert.True(t, userCtx.Local.TOTPEnabled)
}, },
}, },
{ {
@@ -253,6 +251,18 @@ func TestContextMiddleware(t *testing.T) {
req.Header.Set("Authorization", basicAuthHeader("totpuser", "password")) req.Header.Set("Authorization", basicAuthHeader("totpuser", "password"))
userCtx, _ := args.do(req) userCtx, _ := args.do(req)
require.NotNil(t, userCtx)
assert.Equal(t, "testuser", userCtx.GetUsername())
assert.True(t, userCtx.Authenticated)
},
},
{
description: "Ensure fallback to basic auth when cookie is missing",
run: func(t *testing.T, args runArgs) {
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
userCtx, _ := args.do(req)
require.NotNil(t, userCtx) require.NotNil(t, userCtx)
assert.Equal(t, "testuser", userCtx.GetUsername()) assert.Equal(t, "testuser", userCtx.GetUsername())
assert.True(t, userCtx.Authenticated) assert.True(t, userCtx.Authenticated)
+2
View File
@@ -18,6 +18,7 @@ func NewDefaultConfiguration() *Config {
Address: "0.0.0.0", Address: "0.0.0.0",
}, },
Auth: AuthConfig{ Auth: AuthConfig{
SubdomainsEnabled: true,
SessionExpiry: 86400, // 1 day SessionExpiry: 86400, // 1 day
SessionMaxLifetime: 0, // disabled SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes LoginTimeout: 300, // 5 minutes
@@ -102,6 +103,7 @@ type ServerConfig struct {
type AuthConfig struct { type AuthConfig struct {
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"` IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
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"`
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"`
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"` UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
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"`
+59 -15
View File
@@ -34,7 +34,6 @@ type BaseContext struct {
type LocalContext struct { type LocalContext struct {
BaseContext BaseContext
TOTPPending bool TOTPPending bool
TOTPEnabled bool
Attributes UserAttributes Attributes UserAttributes
} }
@@ -56,19 +55,19 @@ func (c *UserContext) IsAuthenticated() bool {
} }
func (c *UserContext) IsLocal() bool { func (c *UserContext) IsLocal() bool {
return c.Provider == ProviderLocal return c.Provider == ProviderLocal && c.Local != nil
} }
func (c *UserContext) IsOAuth() bool { func (c *UserContext) IsOAuth() bool {
return c.Provider == ProviderOAuth return c.Provider == ProviderOAuth && c.OAuth != nil
} }
func (c *UserContext) IsLDAP() bool { func (c *UserContext) IsLDAP() bool {
return c.Provider == ProviderLDAP return c.Provider == ProviderLDAP && c.LDAP != nil
} }
func (c *UserContext) IsBasicAuth() bool { func (c *UserContext) IsBasicAuth() bool {
return c.Provider == ProviderBasicAuth return c.Provider == ProviderBasicAuth && c.Local != nil
} }
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) { func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
@@ -80,16 +79,24 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
userContext, ok := userContextValue.(*UserContext) userContext, ok := userContextValue.(*UserContext)
if !ok { if !ok || userContext == nil {
return nil, errors.New("invalid user context type") return nil, errors.New("invalid user context type")
} }
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil {
return nil, errors.New("incomplete user context")
}
*c = *userContext *c = *userContext
return c, nil return c, nil
} }
// Compatability layer until we get an excuse to drop in database migrations // Compatability layer until we get an excuse to drop in database migrations
func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) { func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) {
*c = UserContext{
Authenticated: !session.TotpPending,
}
switch session.Provider { switch session.Provider {
case "local": case "local":
c.Provider = ProviderLocal c.Provider = ProviderLocal
@@ -119,29 +126,42 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
Name: session.Name, Name: session.Name,
Email: session.Email, Email: session.Email,
}, },
Groups: strings.Split(session.OAuthGroups, ","), Groups: func() []string {
if session.OAuthGroups == "" {
return nil
}
return strings.Split(session.OAuthGroups, ",")
}(),
Sub: session.OAuthSub, Sub: session.OAuthSub,
DisplayName: session.OAuthName, DisplayName: session.OAuthName,
ID: session.Provider, ID: session.Provider,
} }
} }
if !session.TotpPending {
c.Authenticated = true
}
return c, nil return c, nil
} }
func (c *UserContext) GetUsername() string { func (c *UserContext) GetUsername() string {
switch c.Provider { switch c.Provider {
case ProviderLocal: case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Username return c.Local.Username
case ProviderLDAP: case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Username return c.LDAP.Username
case ProviderBasicAuth: case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Username return c.Local.Username
case ProviderOAuth: case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Username return c.OAuth.Username
default: default:
return "" return ""
@@ -151,12 +171,24 @@ func (c *UserContext) GetUsername() string {
func (c *UserContext) GetEmail() string { func (c *UserContext) GetEmail() string {
switch c.Provider { switch c.Provider {
case ProviderLocal: case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Email return c.Local.Email
case ProviderLDAP: case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Email return c.LDAP.Email
case ProviderBasicAuth: case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Email return c.Local.Email
case ProviderOAuth: case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Email return c.OAuth.Email
default: default:
return "" return ""
@@ -166,40 +198,52 @@ func (c *UserContext) GetEmail() string {
func (c *UserContext) GetName() string { func (c *UserContext) GetName() string {
switch c.Provider { switch c.Provider {
case ProviderLocal: case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Name return c.Local.Name
case ProviderLDAP: case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Name return c.LDAP.Name
case ProviderBasicAuth: case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Name return c.Local.Name
case ProviderOAuth: case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Name return c.OAuth.Name
default: default:
return "" return ""
} }
} }
func (c *UserContext) ProviderName() string { func (c *UserContext) GetProviderID() string {
switch c.Provider { switch c.Provider {
case ProviderBasicAuth, ProviderLocal: case ProviderBasicAuth, ProviderLocal:
return "local" return "local"
case ProviderLDAP: case ProviderLDAP:
return "ldap" return "ldap"
case ProviderOAuth: case ProviderOAuth:
return c.OAuth.DisplayName // compatability return c.OAuth.ID
default: default:
return "unknown" return "unknown"
} }
} }
func (c *UserContext) TOTPPending() bool { func (c *UserContext) TOTPPending() bool {
if c.Provider == ProviderLocal { if c.Provider == ProviderLocal && c.Local != nil {
return c.Local.TOTPPending return c.Local.TOTPPending
} }
return false return false
} }
func (c *UserContext) OAuthName() string { func (c *UserContext) OAuthName() string {
if c.Provider == ProviderOAuth { if c.Provider == ProviderOAuth && c.OAuth != nil {
return c.OAuth.DisplayName return c.OAuth.DisplayName
} }
return "" return ""
+61 -41
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
) )
@@ -22,47 +23,48 @@ func TestContext(t *testing.T) {
tests := []struct { tests := []struct {
description string description string
context *model.UserContext context *model.UserContext
run func(*model.UserContext) any run func(*testing.T, *model.UserContext) any
expected any expected any
}{ }{
{ {
description: "IsAuthenticated reflects Authenticated field", description: "IsAuthenticated reflects Authenticated field",
context: &model.UserContext{Authenticated: true}, context: &model.UserContext{Authenticated: true},
run: func(c *model.UserContext) any { return c.IsAuthenticated() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsAuthenticated() },
expected: true, expected: true,
}, },
{ {
description: "IsLocal returns true for ProviderLocal", description: "IsLocal returns true for ProviderLocal",
context: &model.UserContext{Provider: model.ProviderLocal}, context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
run: func(c *model.UserContext) any { return c.IsLocal() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsLocal() },
expected: true, expected: true,
}, },
{ {
description: "IsOAuth returns true for ProviderOAuth", description: "IsOAuth returns true for ProviderOAuth",
context: &model.UserContext{Provider: model.ProviderOAuth}, context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
run: func(c *model.UserContext) any { return c.IsOAuth() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsOAuth() },
expected: true, expected: true,
}, },
{ {
description: "IsLDAP returns true for ProviderLDAP", description: "IsLDAP returns true for ProviderLDAP",
context: &model.UserContext{Provider: model.ProviderLDAP}, context: &model.UserContext{Provider: model.ProviderLDAP, LDAP: &model.LDAPContext{}},
run: func(c *model.UserContext) any { return c.IsLDAP() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsLDAP() },
expected: true, expected: true,
}, },
{ {
description: "IsBasicAuth returns true for ProviderBasicAuth", description: "IsBasicAuth returns true for ProviderBasicAuth",
context: &model.UserContext{Provider: model.ProviderBasicAuth}, context: &model.UserContext{Provider: model.ProviderBasicAuth, Local: &model.LocalContext{}},
run: func(c *model.UserContext) any { return c.IsBasicAuth() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsBasicAuth() },
expected: true, expected: true,
}, },
{ {
description: "NewFromSession local session is authenticated and ProviderLocal", description: "NewFromSession local session is authenticated and ProviderLocal",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, _ := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "alice", Email: "alice@example.com", Name: "Alice", Username: "alice", Email: "alice@example.com", Name: "Alice",
Provider: "local", Provider: "local",
}) })
require.NoError(t, err)
return [2]any{got.Provider, got.Authenticated} return [2]any{got.Provider, got.Authenticated}
}, },
expected: [2]any{model.ProviderLocal, true}, expected: [2]any{model.ProviderLocal, true},
@@ -70,10 +72,11 @@ func TestContext(t *testing.T) {
{ {
description: "NewFromSession local session with TotpPending is not authenticated", description: "NewFromSession local session with TotpPending is not authenticated",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, _ := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "bob", Provider: "local", TotpPending: true, Username: "bob", Provider: "local", TotpPending: true,
}) })
require.NoError(t, err)
return got.Authenticated return got.Authenticated
}, },
expected: false, expected: false,
@@ -81,10 +84,11 @@ func TestContext(t *testing.T) {
{ {
description: "NewFromSession ldap session is ProviderLDAP", description: "NewFromSession ldap session is ProviderLDAP",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, _ := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "carol", Provider: "ldap", Username: "carol", Provider: "ldap",
}) })
require.NoError(t, err)
return got.Provider return got.Provider
}, },
expected: model.ProviderLDAP, expected: model.ProviderLDAP,
@@ -92,11 +96,12 @@ func TestContext(t *testing.T) {
{ {
description: "NewFromSession unknown provider defaults to OAuth and populates oauth fields", description: "NewFromSession unknown provider defaults to OAuth and populates oauth fields",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, _ := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "dave", Provider: "github", Username: "dave", Provider: "github",
OAuthGroups: "devs,admins", OAuthSub: "sub-123", OAuthName: "GitHub", OAuthGroups: "devs,admins", OAuthSub: "sub-123", OAuthName: "GitHub",
}) })
require.NoError(t, err)
return [5]any{got.Provider, got.OAuth.ID, got.OAuth.Sub, got.OAuth.DisplayName, got.OAuth.Groups} return [5]any{got.Provider, got.OAuth.ID, got.OAuth.Sub, got.OAuth.DisplayName, got.OAuth.Groups}
}, },
expected: [5]any{model.ProviderOAuth, "github", "sub-123", "GitHub", []string{"devs", "admins"}}, expected: [5]any{model.ProviderOAuth, "github", "sub-123", "GitHub", []string{"devs", "admins"}},
@@ -107,7 +112,7 @@ func TestContext(t *testing.T) {
Provider: model.ProviderLocal, Provider: model.ProviderLocal,
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice", Email: "alice@example.com", Name: "Alice"}}, Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice", Email: "alice@example.com", Name: "Alice"}},
}, },
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"alice", "alice@example.com", "Alice"}, expected: [3]string{"alice", "alice@example.com", "Alice"},
@@ -118,7 +123,7 @@ func TestContext(t *testing.T) {
Provider: model.ProviderBasicAuth, Provider: model.ProviderBasicAuth,
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "bob", Email: "bob@example.com", Name: "Bob"}}, Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "bob", Email: "bob@example.com", Name: "Bob"}},
}, },
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"bob", "bob@example.com", "Bob"}, expected: [3]string{"bob", "bob@example.com", "Bob"},
@@ -129,7 +134,7 @@ func TestContext(t *testing.T) {
Provider: model.ProviderLDAP, Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{BaseContext: model.BaseContext{Username: "carol", Email: "carol@example.com", Name: "Carol"}}, LDAP: &model.LDAPContext{BaseContext: model.BaseContext{Username: "carol", Email: "carol@example.com", Name: "Carol"}},
}, },
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"carol", "carol@example.com", "Carol"}, expected: [3]string{"carol", "carol@example.com", "Carol"},
@@ -140,7 +145,7 @@ func TestContext(t *testing.T) {
Provider: model.ProviderOAuth, Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{BaseContext: model.BaseContext{Username: "dave", Email: "dave@example.com", Name: "Dave"}}, OAuth: &model.OAuthContext{BaseContext: model.BaseContext{Username: "dave", Email: "dave@example.com", Name: "Dave"}},
}, },
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"dave", "dave@example.com", "Dave"}, expected: [3]string{"dave", "dave@example.com", "Dave"},
@@ -148,29 +153,29 @@ func TestContext(t *testing.T) {
{ {
description: "ProviderName returns 'local' for ProviderLocal", description: "ProviderName returns 'local' for ProviderLocal",
context: &model.UserContext{Provider: model.ProviderLocal}, context: &model.UserContext{Provider: model.ProviderLocal},
run: func(c *model.UserContext) any { return c.ProviderName() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "local", expected: "local",
}, },
{ {
description: "ProviderName returns 'local' for ProviderBasicAuth", description: "ProviderName returns 'local' for ProviderBasicAuth",
context: &model.UserContext{Provider: model.ProviderBasicAuth}, context: &model.UserContext{Provider: model.ProviderBasicAuth},
run: func(c *model.UserContext) any { return c.ProviderName() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "local", expected: "local",
}, },
{ {
description: "ProviderName returns 'ldap' for ProviderLDAP", description: "ProviderName returns 'ldap' for ProviderLDAP",
context: &model.UserContext{Provider: model.ProviderLDAP}, context: &model.UserContext{Provider: model.ProviderLDAP},
run: func(c *model.UserContext) any { return c.ProviderName() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "ldap", expected: "ldap",
}, },
{ {
description: "ProviderName returns OAuth DisplayName for ProviderOAuth", description: "ProviderName returns OAuth provider ID for ProviderOAuth",
context: &model.UserContext{ context: &model.UserContext{
Provider: model.ProviderOAuth, Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{DisplayName: "GitHub"}, OAuth: &model.OAuthContext{ID: "github"},
}, },
run: func(c *model.UserContext) any { return c.ProviderName() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "GitHub", expected: "github",
}, },
{ {
description: "TOTPPending returns true when local context is pending", description: "TOTPPending returns true when local context is pending",
@@ -178,7 +183,7 @@ func TestContext(t *testing.T) {
Provider: model.ProviderLocal, Provider: model.ProviderLocal,
Local: &model.LocalContext{TOTPPending: true}, Local: &model.LocalContext{TOTPPending: true},
}, },
run: func(c *model.UserContext) any { return c.TOTPPending() }, run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
expected: true, expected: true,
}, },
{ {
@@ -187,13 +192,13 @@ func TestContext(t *testing.T) {
Provider: model.ProviderLocal, Provider: model.ProviderLocal,
Local: &model.LocalContext{TOTPPending: false}, Local: &model.LocalContext{TOTPPending: false},
}, },
run: func(c *model.UserContext) any { return c.TOTPPending() }, run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
expected: false, expected: false,
}, },
{ {
description: "TOTPPending returns false for non-local providers", description: "TOTPPending returns false for non-local providers",
context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}}, context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
run: func(c *model.UserContext) any { return c.TOTPPending() }, run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
expected: false, expected: false,
}, },
{ {
@@ -202,28 +207,26 @@ func TestContext(t *testing.T) {
Provider: model.ProviderOAuth, Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{DisplayName: "Google"}, OAuth: &model.OAuthContext{DisplayName: "Google"},
}, },
run: func(c *model.UserContext) any { return c.OAuthName() }, run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
expected: "Google", expected: "Google",
}, },
{ {
description: "OAuthName returns empty string for non-oauth providers", description: "OAuthName returns empty string for non-oauth providers",
context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}}, context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
run: func(c *model.UserContext) any { return c.OAuthName() }, run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
expected: "", expected: "",
}, },
{ {
description: "NewFromGin populates context from gin value", description: "NewFromGin populates context from gin value",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
stored := &model.UserContext{ stored := &model.UserContext{
Authenticated: true, Authenticated: true,
Provider: model.ProviderLocal, Provider: model.ProviderLocal,
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice"}}, Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice"}},
} }
got, err := c.NewFromGin(newGinCtx(stored, true)) got, err := c.NewFromGin(newGinCtx(stored, true))
if err != nil { require.NoError(t, err)
return err.Error()
}
return [2]any{got.Authenticated, got.GetUsername()} return [2]any{got.Authenticated, got.GetUsername()}
}, },
expected: [2]any{true, "alice"}, expected: [2]any{true, "alice"},
@@ -231,7 +234,7 @@ func TestContext(t *testing.T) {
{ {
description: "NewFromGin returns error when context value is missing", description: "NewFromGin returns error when context value is missing",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
_, err := c.NewFromGin(newGinCtx(nil, false)) _, err := c.NewFromGin(newGinCtx(nil, false))
return err.Error() return err.Error()
}, },
@@ -240,17 +243,34 @@ func TestContext(t *testing.T) {
{ {
description: "NewFromGin returns error when context value has wrong type", description: "NewFromGin returns error when context value has wrong type",
context: &model.UserContext{}, context: &model.UserContext{},
run: func(c *model.UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
_, err := c.NewFromGin(newGinCtx("not a user context", true)) _, err := c.NewFromGin(newGinCtx("not a user context", true))
return err.Error() return err.Error()
}, },
expected: "invalid user context type", expected: "invalid user context type",
}, },
{
description: "NewFromGin returns an error when context doesn't include user information",
context: &model.UserContext{},
run: func(t *testing.T, c *model.UserContext) any {
_, err := c.NewFromGin(newGinCtx(&model.UserContext{Provider: model.ProviderLocal}, true))
return err.Error()
},
expected: "incomplete user context",
},
{
description: "Getters should not panic if provider context is empty",
context: &model.UserContext{Provider: model.ProviderLocal},
run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
},
expected: [3]string{"", "", ""},
},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
assert.Equal(t, test.expected, test.run(test.context)) assert.Equal(t, test.expected, test.run(t, test.context))
}) })
} }
} }
+6 -3
View File
@@ -28,18 +28,21 @@ func (acls *AccessControlsService) Init() error {
} }
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App { func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var appAcls *model.App
for app, config := range acls.static { for app, config := range acls.static {
if config.Config.Domain == domain { if config.Config.Domain == domain {
tlog.App.Debug().Str("name", app).Msg("Found matching container by domain") tlog.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config appAcls = &config
break // If we find a match by domain, we can stop searching
} }
if strings.SplitN(domain, ".", 2)[0] == app { if strings.SplitN(domain, ".", 2)[0] == app {
tlog.App.Debug().Str("name", app).Msg("Found matching container by app name") tlog.App.Debug().Str("name", app).Msg("Found matching container by app name")
return &config appAcls = &config
break // If we find a match by app name, we can stop searching
} }
} }
return nil return appAcls
} }
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
+30 -8
View File
@@ -73,7 +73,7 @@ type Lockdown struct {
} }
type AuthServiceConfig struct { type AuthServiceConfig struct {
LocalUsers []model.LocalUser LocalUsers *[]model.LocalUser
OauthWhitelist []string OauthWhitelist []string
SessionExpiry int SessionExpiry int
SessionMaxLifetime int SessionMaxLifetime int
@@ -84,6 +84,7 @@ type AuthServiceConfig struct {
SessionCookieName string SessionCookieName string
IP model.IPConfig IP model.IPConfig
LDAPGroupsCacheTTL int LDAPGroupsCacheTTL int
SubdomainsEnabled bool
} }
type AuthService struct { type AuthService struct {
@@ -120,7 +121,7 @@ func (auth *AuthService) Init() error {
} }
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) { func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
if auth.GetLocalUser(username).Username != "" { if auth.GetLocalUser(username) != nil {
return &model.UserSearch{ return &model.UserSearch{
Username: username, Username: username,
Type: model.UserLocal, Type: model.UserLocal,
@@ -147,6 +148,9 @@ func (auth *AuthService) CheckUserPassword(search model.UserSearch, password str
switch search.Type { switch search.Type {
case model.UserLocal: case model.UserLocal:
user := auth.GetLocalUser(search.Username) user := auth.GetLocalUser(search.Username)
if user == nil {
return ErrUserNotFound
}
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
case model.UserLDAP: case model.UserLDAP:
if auth.ldap.IsConfigured() { if auth.ldap.IsConfigured() {
@@ -169,7 +173,10 @@ func (auth *AuthService) CheckUserPassword(search model.UserSearch, password str
} }
func (auth *AuthService) GetLocalUser(username string) *model.LocalUser { func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
for _, user := range auth.config.LocalUsers { if auth.config.LocalUsers == nil {
return nil
}
for _, user := range *auth.config.LocalUsers {
if user.Username == username { if user.Username == username {
return &user return &user
} }
@@ -295,6 +302,8 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
expiry = auth.config.SessionExpiry expiry = auth.config.SessionExpiry
} }
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
session := repository.CreateSessionParams{ session := repository.CreateSessionParams{
UUID: uuid.String(), UUID: uuid.String(),
Username: data.Username, Username: data.Username,
@@ -303,7 +312,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
Provider: data.Provider, Provider: data.Provider,
TotpPending: data.TotpPending, TotpPending: data.TotpPending,
OAuthGroups: data.OAuthGroups, OAuthGroups: data.OAuthGroups,
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(), Expiry: expiresAt.Unix(),
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),
OAuthName: data.OAuthName, OAuthName: data.OAuthName,
OAuthSub: data.OAuthSub, OAuthSub: data.OAuthSub,
@@ -320,8 +329,8 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
Value: session.UUID, Value: session.UUID,
Path: "/", Path: "/",
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain), Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now().Add(time.Duration(expiry) * time.Second), Expires: expiresAt,
MaxAge: expiry, MaxAge: int(time.Until(expiresAt).Seconds()),
Secure: auth.config.SecureCookie, Secure: auth.config.SecureCookie,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
@@ -374,7 +383,7 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
Path: "/", Path: "/",
Domain: fmt.Sprintf(".%s", auth.config.CookieDomain), Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second), Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
MaxAge: auth.config.SessionExpiry, MaxAge: int(newExpiry - currentTime),
Secure: auth.config.SecureCookie, Secure: auth.config.SecureCookie,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
@@ -389,6 +398,12 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
tlog.App.Warn().Err(err).Msg("Failed to delete session from database, proceeding to clear cookie anyway") tlog.App.Warn().Err(err).Msg("Failed to delete session from database, proceeding to clear cookie anyway")
} }
err = auth.queries.DeleteSession(ctx, uuid)
if err != nil {
return nil, err
}
return &http.Cookie{ return &http.Cookie{
Name: auth.config.SessionCookieName, Name: auth.config.SessionCookieName,
Value: "", Value: "",
@@ -436,7 +451,7 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
} }
func (auth *AuthService) LocalAuthConfigured() bool { func (auth *AuthService) LocalAuthConfigured() bool {
return len(auth.config.LocalUsers) > 0 return auth.config.LocalUsers != nil && len(*auth.config.LocalUsers) > 0
} }
func (auth *AuthService) LDAPAuthConfigured() bool { func (auth *AuthService) LDAPAuthConfigured() bool {
@@ -830,3 +845,10 @@ func (auth *AuthService) ClearRateLimitsTestingOnly() {
} }
auth.loginMutex.Unlock() auth.loginMutex.Unlock()
} }
func (auth *AuthService) getCookieDomain() string {
if auth.config.SubdomainsEnabled {
return "." + auth.config.CookieDomain
}
return auth.config.CookieDomain
}
+4 -2
View File
@@ -95,7 +95,8 @@ func (k *KubernetesService) getByDomain(domain string) *model.App {
if appKey, ok := k.domainIndex[domain]; ok { if appKey, ok := k.domainIndex[domain]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok { if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for _, app := range apps { for i := range apps {
app := &apps[i]
if app.domain == domain && app.appName == appKey.appName { if app.domain == domain && app.appName == appKey.appName {
return &app.app return &app.app
} }
@@ -111,7 +112,8 @@ func (k *KubernetesService) getByAppName(appName string) *model.App {
if appKey, ok := k.appNameIndex[appName]; ok { if appKey, ok := k.appNameIndex[appName]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok { if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for _, app := range apps { for i := range apps {
app := &apps[i]
if app.appName == appName { if app.appName == appName {
return &app.app return &app.app
} }
+15 -5
View File
@@ -12,8 +12,9 @@ import (
) )
type GithubEmailResponse []struct { type GithubEmailResponse []struct {
Email string `json:"email"` Email string `json:"email"`
Primary bool `json:"primary"` Primary bool `json:"primary"`
Verified bool `json:"verified"`
} }
type GithubUserInfoResponse struct { type GithubUserInfoResponse struct {
@@ -26,7 +27,7 @@ func defaultExtractor(client *http.Client, url string) (*model.Claims, error) {
return simpleReq[model.Claims](client, url, nil) return simpleReq[model.Claims](client, url, nil)
} }
func githubExtractor(client *http.Client, url string) (*model.Claims, error) { func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
var user model.Claims var user model.Claims
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{ userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
@@ -48,7 +49,7 @@ func githubExtractor(client *http.Client, url string) (*model.Claims, error) {
} }
for _, email := range *userEmails { for _, email := range *userEmails {
if email.Primary { if email.Primary && email.Verified {
user.Email = email.Email user.Email = email.Email
break break
} }
@@ -56,7 +57,16 @@ func githubExtractor(client *http.Client, url string) (*model.Claims, error) {
// Use first available email if no primary email was found // Use first available email if no primary email was found
if user.Email == "" { if user.Email == "" {
user.Email = (*userEmails)[0].Email for _, email := range *userEmails {
if email.Verified {
user.Email = email.Email
break
}
}
}
if user.Email == "" {
return nil, errors.New("no verified email found")
} }
user.PreferredUsername = userInfo.Login user.PreferredUsername = userInfo.Login
+9
View File
@@ -47,6 +47,15 @@ func GetCookieDomain(u string) (string, error) {
return domain, nil return domain, nil
} }
func GetStandaloneCookieDomain(u string) (string, error) {
parsed, err := url.Parse(u)
if err != nil {
return "", err
}
return parsed.Hostname(), nil
}
func ParseFileToLine(content string) string { func ParseFileToLine(content string) string {
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
users := make([]string, 0) users := make([]string, 0)
+4 -3
View File
@@ -5,18 +5,19 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestReadFile(t *testing.T) { func TestReadFile(t *testing.T) {
// Setup // Setup
file, err := os.Create("/tmp/tinyauth_test_file") file, err := os.Create("/tmp/tinyauth_test_file")
assert.NoError(t, err) require.NoError(t, err)
_, err = file.WriteString("file content\n") _, err = file.WriteString("file content\n")
assert.NoError(t, err) require.NoError(t, err)
err = file.Close() err = file.Close()
assert.NoError(t, err) require.NoError(t, err)
defer os.Remove("/tmp/tinyauth_test_file") defer os.Remove("/tmp/tinyauth_test_file")
// Normal case // Normal case
+4 -3
View File
@@ -5,19 +5,20 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
) )
func TestGetSecret(t *testing.T) { func TestGetSecret(t *testing.T) {
// Setup // Setup
file, err := os.Create("/tmp/tinyauth_test_secret") file, err := os.Create("/tmp/tinyauth_test_secret")
assert.NoError(t, err) require.NoError(t, err)
_, err = file.WriteString(" secret \n") _, err = file.WriteString(" secret \n")
assert.NoError(t, err) require.NoError(t, err)
err = file.Close() err = file.Close()
assert.NoError(t, err) require.NoError(t, err)
defer os.Remove("/tmp/tinyauth_test_secret") defer os.Remove("/tmp/tinyauth_test_secret")
// Get from config // Get from config
+12 -9
View File
@@ -5,28 +5,31 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
) )
func TestGetUsers(t *testing.T) { func TestGetUsers(t *testing.T) {
tmpDir := t.TempDir()
hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G" hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"
// Setup // Setup
file, err := os.Create("/tmp/tinyauth_users_test.txt") file, err := os.Create(tmpDir + "/tinyauth_users_test.txt")
assert.NoError(t, err) require.NoError(t, err)
_, err = file.WriteString(" user1:" + hash + " \n user2:" + hash + " ") // Spacing is on purpose _, err = file.WriteString(" user1:" + hash + " \n user2:" + hash + " ") // Spacing is on purpose
assert.NoError(t, err) require.NoError(t, err)
err = file.Close() err = file.Close()
assert.NoError(t, err) require.NoError(t, err)
defer os.Remove("/tmp/tinyauth_users_test.txt") defer os.Remove(tmpDir + "/tinyauth_users_test.txt")
noAttrs := map[string]model.UserAttributes{} noAttrs := map[string]model.UserAttributes{}
// Test file only // Test file only
users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt", noAttrs) users, err := utils.GetUsers([]string{}, tmpDir+"/tinyauth_users_test.txt", noAttrs)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, users) assert.NotNil(t, users)
@@ -47,7 +50,7 @@ func TestGetUsers(t *testing.T) {
assert.Equal(t, "user4", (*users)[1].Username) assert.Equal(t, "user4", (*users)[1].Username)
// Test both // Test both
users, err = utils.GetUsers([]string{"user5:" + hash}, "/tmp/tinyauth_users_test.txt", noAttrs) users, err = utils.GetUsers([]string{"user5:" + hash}, tmpDir+"/tinyauth_users_test.txt", noAttrs)
assert.NoError(t, err) assert.NoError(t, err)
@@ -65,7 +68,7 @@ func TestGetUsers(t *testing.T) {
attrs := map[string]model.UserAttributes{ attrs := map[string]model.UserAttributes{
"user1": {Name: "User One", Email: "user1@example.com"}, "user1": {Name: "User One", Email: "user1@example.com"},
} }
users, err = utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt", attrs) users, err = utils.GetUsers([]string{}, tmpDir+"/tinyauth_users_test.txt", attrs)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, *users, 2) assert.Len(t, *users, 2)
@@ -87,7 +90,7 @@ func TestGetUsers(t *testing.T) {
assert.Nil(t, users) assert.Nil(t, users)
// Test non-existent file // Test non-existent file
users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt", noAttrs) users, err = utils.GetUsers([]string{}, tmpDir+"/non_existent_file.txt", noAttrs)
assert.ErrorContains(t, err, "no such file or directory") assert.ErrorContains(t, err, "no such file or directory")
assert.Nil(t, users) assert.Nil(t, users)