mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-07-09 19:50:18 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aeb886523 | |||
| 1382ab41e7 | |||
| 24f2da4e58 | |||
| b06b60150f | |||
| 4077bacfdf | |||
| 4c0181c5e2 | |||
| 44a7cbf41b | |||
| d90e3d652d |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user