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
This commit is contained in:
Stavros
2026-05-07 15:41:07 +03:00
committed by GitHub
parent 24f2da4e58
commit 1382ab41e7
58 changed files with 2070 additions and 1117 deletions
-18
View File
@@ -7,10 +7,8 @@ import (
"net/url"
"strings"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin"
"github.com/weppos/publicsuffix-go/publicsuffix"
)
@@ -73,22 +71,6 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
return res
}
func GetContext(c *gin.Context) (config.UserContext, error) {
userContextValue, exists := c.Get("context")
if !exists {
return config.UserContext{}, errors.New("no user context in request")
}
userContext, ok := userContextValue.(*config.UserContext)
if !ok {
return config.UserContext{}, errors.New("invalid user context in request")
}
return *userContext, nil
}
func IsRedirectSafe(redirectURL string, domain string) bool {
if redirectURL == "" {
return false
+20 -45
View File
@@ -3,11 +3,8 @@ package utils_test
import (
"testing"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
)
func TestGetRootDomain(t *testing.T) {
@@ -15,14 +12,14 @@ func TestGetRootDomain(t *testing.T) {
domain := "http://sub.tinyauth.app"
expected := "tinyauth.app"
result, err := utils.GetCookieDomain(domain)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Domain with multiple subdomains
domain = "http://b.c.tinyauth.app"
expected = "c.tinyauth.app"
result, err = utils.GetCookieDomain(domain)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Invalid domain (only TLD)
@@ -44,14 +41,14 @@ func TestGetRootDomain(t *testing.T) {
domain = "https://sub.tinyauth.app/path"
expected = "tinyauth.app"
result, err = utils.GetCookieDomain(domain)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// URL with port
domain = "http://sub.tinyauth.app:8080"
expected = "tinyauth.app"
result, err = utils.GetCookieDomain(domain)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Domain managed by ICANN
@@ -98,57 +95,35 @@ func TestFilter(t *testing.T) {
testFunc := func(n int) bool { return n%2 == 0 }
expected := []int{2, 4}
result := utils.Filter(slice, testFunc)
assert.DeepEqual(t, expected, result)
assert.Equal(t, expected, result)
// Case with no matches
slice = []int{1, 3, 5}
testFunc = func(n int) bool { return n%2 == 0 }
expected = []int{}
result = utils.Filter(slice, testFunc)
assert.DeepEqual(t, expected, result)
assert.Equal(t, expected, result)
// Case with all matches
slice = []int{2, 4, 6}
testFunc = func(n int) bool { return n%2 == 0 }
expected = []int{2, 4, 6}
result = utils.Filter(slice, testFunc)
assert.DeepEqual(t, expected, result)
assert.Equal(t, expected, result)
// Case with empty slice
slice = []int{}
testFunc = func(n int) bool { return n%2 == 0 }
expected = []int{}
result = utils.Filter(slice, testFunc)
assert.DeepEqual(t, expected, result)
assert.Equal(t, expected, result)
// Case with different type (string)
sliceStr := []string{"apple", "banana", "cherry"}
testFuncStr := func(s string) bool { return len(s) > 5 }
expectedStr := []string{"banana", "cherry"}
resultStr := utils.Filter(sliceStr, testFuncStr)
assert.DeepEqual(t, expectedStr, resultStr)
}
func TestGetContext(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
// Normal case
c.Set("context", &config.UserContext{Username: "testuser"})
result, err := utils.GetContext(c)
assert.NilError(t, err)
assert.Equal(t, "testuser", result.Username)
// Case with no context
c.Set("context", nil)
_, err = utils.GetContext(c)
assert.Error(t, err, "invalid user context in request")
// Case with invalid context type
c.Set("context", "invalid type")
_, err = utils.GetContext(c)
assert.Error(t, err, "invalid user context in request")
assert.Equal(t, expectedStr, resultStr)
}
func TestIsRedirectSafe(t *testing.T) {
@@ -158,50 +133,50 @@ func TestIsRedirectSafe(t *testing.T) {
// Case with no subdomain
redirectURL := "http://example.com/welcome"
result := utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, true, result)
assert.True(t, result)
// Case with different domain
redirectURL = "http://malicious.com/phishing"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
assert.False(t, result)
// Case with subdomain
redirectURL = "http://sub.example.com/page"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, true, result)
assert.True(t, result)
// Case with sub-subdomain
redirectURL = "http://a.b.example.com/home"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, true, result)
assert.True(t, result)
// Case with empty redirect URL
redirectURL = ""
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
assert.False(t, result)
// Case with invalid URL
redirectURL = "http://[::1]:namedport"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
assert.False(t, result)
// Case with URL having port
redirectURL = "http://sub.example.com:8080/page"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, true, result)
assert.True(t, result)
// Case with URL having different subdomain
redirectURL = "http://another.example.com/page"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, true, result)
assert.True(t, result)
// Case with URL having different TLD
redirectURL = "http://example.org/page"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
assert.False(t, result)
// Case with malicious domain
redirectURL = "https://malicious-example.com/yoyo"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
assert.False(t, result)
}
+14 -15
View File
@@ -3,42 +3,41 @@ package decoders_test
import (
"testing"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"gotest.tools/v3/assert"
)
func TestDecodeLabels(t *testing.T) {
// Variables
expected := config.Apps{
Apps: map[string]config.App{
expected := model.Apps{
Apps: map[string]model.App{
"foo": {
Config: config.AppConfig{
Config: model.AppConfig{
Domain: "example.com",
},
Users: config.AppUsers{
Users: model.AppUsers{
Allow: "user1,user2",
Block: "user3",
},
OAuth: config.AppOAuth{
OAuth: model.AppOAuth{
Whitelist: "somebody@example.com",
Groups: "group3",
},
IP: config.AppIP{
IP: model.AppIP{
Allow: []string{"10.71.0.1/24", "10.71.0.2"},
Block: []string{"10.10.10.10", "10.0.0.0/24"},
Bypass: []string{"192.168.1.1"},
},
Response: config.AppResponse{
Response: model.AppResponse{
Headers: []string{"X-Foo=Bar", "X-Baz=Qux"},
BasicAuth: config.AppBasicAuth{
BasicAuth: model.AppBasicAuth{
Username: "admin",
Password: "password",
PasswordFile: "/path/to/passwordfile",
},
},
Path: config.AppPath{
Path: model.AppPath{
Allow: "/public",
Block: "/private",
},
@@ -63,7 +62,7 @@ func TestDecodeLabels(t *testing.T) {
}
// Test
result, err := decoders.DecodeLabels[config.Apps](test, "apps")
assert.NilError(t, err)
assert.DeepEqual(t, expected, result)
result, err := decoders.DecodeLabels[model.Apps](test, "apps")
assert.NoError(t, err)
assert.Equal(t, expected, result)
}
+6 -5
View File
@@ -4,24 +4,25 @@ import (
"os"
"testing"
"gotest.tools/v3/assert"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadFile(t *testing.T) {
// Setup
file, err := os.Create("/tmp/tinyauth_test_file")
assert.NilError(t, err)
require.NoError(t, err)
_, err = file.WriteString("file content\n")
assert.NilError(t, err)
require.NoError(t, err)
err = file.Close()
assert.NilError(t, err)
require.NoError(t, err)
defer os.Remove("/tmp/tinyauth_test_file")
// Normal case
content, err := ReadFile("/tmp/tinyauth_test_file")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, "file content\n", content)
// Non-existing file
+6 -7
View File
@@ -3,9 +3,8 @@ package utils_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/utils"
"gotest.tools/v3/assert"
)
func TestParseHeaders(t *testing.T) {
@@ -18,7 +17,7 @@ func TestParseHeaders(t *testing.T) {
"X-Custom-Header": "Value",
"Another-Header": "AnotherValue",
}
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
assert.Equal(t, expected, utils.ParseHeaders(headers))
// Case insensitivity and trimming
headers = []string{
@@ -29,7 +28,7 @@ func TestParseHeaders(t *testing.T) {
"X-Custom-Header": "Value",
"Another-Header": "AnotherValue",
}
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
assert.Equal(t, expected, utils.ParseHeaders(headers))
// Invalid headers (missing '=', empty key/value)
headers = []string{
@@ -39,7 +38,7 @@ func TestParseHeaders(t *testing.T) {
" = ",
}
expected = map[string]string{}
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
assert.Equal(t, expected, utils.ParseHeaders(headers))
// Headers with unsafe characters
headers = []string{
@@ -52,7 +51,7 @@ func TestParseHeaders(t *testing.T) {
"Another-Header": "AnotherValue",
"Good-Header": "GoodValue",
}
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
assert.Equal(t, expected, utils.ParseHeaders(headers))
// Header with spaces in key (should be ignored)
headers = []string{
@@ -62,7 +61,7 @@ func TestParseHeaders(t *testing.T) {
expected = map[string]string{
"Valid-Header": "ValidValue",
}
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
assert.Equal(t, expected, utils.ParseHeaders(headers))
}
func TestSanitizeHeader(t *testing.T) {
+3 -4
View File
@@ -4,21 +4,20 @@ import (
"fmt"
"os"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/paerser/env"
"github.com/tinyauthapp/tinyauth/internal/model"
)
type EnvLoader struct{}
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
vars := env.FindPrefixedEnvVars(os.Environ(), model.DefaultNamePrefix, cmd.Configuration)
if len(vars) == 0 {
return false, nil
}
if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {
if err := env.Decode(vars, model.DefaultNamePrefix, cmd.Configuration); err != nil {
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
}
+1 -1
View File
@@ -41,7 +41,7 @@ func ParseSecretFile(contents string) string {
return ""
}
func GetBasicAuth(username string, password string) string {
func EncodeBasicAuth(username string, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}
+15 -15
View File
@@ -4,21 +4,21 @@ import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/utils"
"gotest.tools/v3/assert"
)
func TestGetSecret(t *testing.T) {
// Setup
file, err := os.Create("/tmp/tinyauth_test_secret")
assert.NilError(t, err)
require.NoError(t, err)
_, err = file.WriteString(" secret \n")
assert.NilError(t, err)
require.NoError(t, err)
err = file.Close()
assert.NilError(t, err)
require.NoError(t, err)
defer os.Remove("/tmp/tinyauth_test_secret")
// Get from config
@@ -55,50 +55,50 @@ func TestParseSecretFile(t *testing.T) {
assert.Equal(t, "", utils.ParseSecretFile(content))
}
func TestGetBasicAuth(t *testing.T) {
func TestEncodeBasicAuth(t *testing.T) {
// Normal case
username := "user"
password := "pass"
expected := "dXNlcjpwYXNz" // base64 of "user:pass"
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
// Empty username
username = ""
password = "pass"
expected = "OnBhc3M=" // base64 of ":pass"
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
// Empty password
username = "user"
password = ""
expected = "dXNlcjo=" // base64 of "user:"
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
}
func TestFilterIP(t *testing.T) {
// Exact match IPv4
ok, err := utils.FilterIP("10.10.0.1", "10.10.0.1")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, true, ok)
// Non-match IPv4
ok, err = utils.FilterIP("10.10.0.1", "10.10.0.2")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, false, ok)
// CIDR match IPv4
ok, err = utils.FilterIP("10.10.0.0/24", "10.10.0.2")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, true, ok)
// CIDR match IPv4 with '-' instead of '/'
ok, err = utils.FilterIP("10.10.10.0-24", "10.10.10.5")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, true, ok)
// CIDR non-match IPv4
ok, err = utils.FilterIP("10.10.0.0/24", "10.5.0.1")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, false, ok)
// Invalid CIDR
@@ -145,5 +145,5 @@ func TestGenerateUUID(t *testing.T) {
// Different output for different input
id3 := utils.GenerateUUID("differentstring")
assert.Assert(t, id1 != id3)
assert.NotEqual(t, id2, id3)
}
+1 -2
View File
@@ -3,9 +3,8 @@ package utils_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/utils"
"gotest.tools/v3/assert"
)
func TestCapitalize(t *testing.T) {
+13 -13
View File
@@ -7,7 +7,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/model"
)
type Logger struct {
@@ -22,7 +22,7 @@ var (
App zerolog.Logger
)
func NewLogger(cfg config.LogConfig) *Logger {
func NewLogger(cfg model.LogConfig) *Logger {
baseLogger := log.With().
Timestamp().
Caller().
@@ -44,24 +44,24 @@ func NewLogger(cfg config.LogConfig) *Logger {
}
func NewSimpleLogger() *Logger {
return NewLogger(config.LogConfig{
return NewLogger(model.LogConfig{
Level: "info",
Json: false,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: false},
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: true},
App: model.LogStreamConfig{Enabled: true},
Audit: model.LogStreamConfig{Enabled: false},
},
})
}
func NewTestLogger() *Logger {
return NewLogger(config.LogConfig{
return NewLogger(model.LogConfig{
Level: "trace",
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: true},
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: true},
App: model.LogStreamConfig{Enabled: true},
Audit: model.LogStreamConfig{Enabled: true},
},
})
}
@@ -72,7 +72,7 @@ func (l *Logger) Init() {
App = l.App
}
func createLogger(component string, streamCfg config.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
func createLogger(component string, streamCfg model.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
if !streamCfg.Enabled {
return zerolog.Nop()
}
+30 -30
View File
@@ -5,75 +5,75 @@ import (
"encoding/json"
"testing"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog"
"gotest.tools/v3/assert"
)
func TestNewLogger(t *testing.T) {
cfg := config.LogConfig{
cfg := model.LogConfig{
Level: "debug",
Json: true,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true, Level: "info"},
App: config.LogStreamConfig{Enabled: true, Level: ""},
Audit: config.LogStreamConfig{Enabled: false, Level: ""},
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: true, Level: "info"},
App: model.LogStreamConfig{Enabled: true, Level: ""},
Audit: model.LogStreamConfig{Enabled: false, Level: ""},
},
}
logger := tlog.NewLogger(cfg)
assert.Assert(t, logger != nil)
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)
assert.Assert(t, logger.App.GetLevel() == zerolog.DebugLevel)
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
assert.NotNil(t, logger)
assert.Equal(t, zerolog.InfoLevel, logger.HTTP.GetLevel())
assert.Equal(t, zerolog.DebugLevel, logger.App.GetLevel())
assert.Equal(t, zerolog.Disabled, logger.Audit.GetLevel())
}
func TestNewSimpleLogger(t *testing.T) {
logger := tlog.NewSimpleLogger()
assert.Assert(t, logger != nil)
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)
assert.Assert(t, logger.App.GetLevel() == zerolog.InfoLevel)
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
assert.NotNil(t, logger)
assert.Equal(t, zerolog.InfoLevel, logger.HTTP.GetLevel())
assert.Equal(t, zerolog.InfoLevel, logger.App.GetLevel())
assert.Equal(t, zerolog.Disabled, logger.Audit.GetLevel())
}
func TestLoggerInit(t *testing.T) {
logger := tlog.NewSimpleLogger()
logger.Init()
assert.Assert(t, tlog.App.GetLevel() != zerolog.Disabled)
assert.NotEqual(t, zerolog.Disabled, tlog.App.GetLevel())
}
func TestLoggerWithDisabledStreams(t *testing.T) {
cfg := config.LogConfig{
cfg := model.LogConfig{
Level: "info",
Json: false,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: false},
App: config.LogStreamConfig{Enabled: false},
Audit: config.LogStreamConfig{Enabled: false},
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: false},
App: model.LogStreamConfig{Enabled: false},
Audit: model.LogStreamConfig{Enabled: false},
},
}
logger := tlog.NewLogger(cfg)
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.Disabled)
assert.Assert(t, logger.App.GetLevel() == zerolog.Disabled)
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
assert.Equal(t, zerolog.Disabled, logger.HTTP.GetLevel())
assert.Equal(t, zerolog.Disabled, logger.App.GetLevel())
assert.Equal(t, zerolog.Disabled, logger.Audit.GetLevel())
}
func TestLogStreamField(t *testing.T) {
var buf bytes.Buffer
cfg := config.LogConfig{
cfg := model.LogConfig{
Level: "info",
Json: true,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: true},
Streams: model.LogStreams{
HTTP: model.LogStreamConfig{Enabled: true},
App: model.LogStreamConfig{Enabled: true},
Audit: model.LogStreamConfig{Enabled: true},
},
}
@@ -86,7 +86,7 @@ func TestLogStreamField(t *testing.T) {
var logEntry map[string]interface{}
err := json.Unmarshal(buf.Bytes(), &logEntry)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, "http", logEntry["log_stream"])
assert.Equal(t, "test message", logEntry["message"])
+16 -16
View File
@@ -6,14 +6,14 @@ import (
"net/mail"
"strings"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/model"
)
func ParseUsers(usersStr []string, userAttributes map[string]config.UserAttributes) ([]config.User, error) {
var users []config.User
func ParseUsers(usersStr []string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
var users []model.LocalUser
if len(usersStr) == 0 {
return []config.User{}, nil
return &users, nil
}
for _, user := range usersStr {
@@ -22,22 +22,22 @@ func ParseUsers(usersStr []string, userAttributes map[string]config.UserAttribut
}
parsed, err := ParseUser(strings.TrimSpace(user))
if err != nil {
return []config.User{}, err
return nil, err
}
if attrs, ok := userAttributes[parsed.Username]; ok {
parsed.Attributes = attrs
}
users = append(users, parsed)
users = append(users, *parsed)
}
return users, nil
return &users, nil
}
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]config.UserAttributes) ([]config.User, error) {
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
var usersStr []string
if len(usersCfg) == 0 && usersPath == "" {
return []config.User{}, nil
return nil, nil
}
if len(usersCfg) > 0 {
@@ -48,7 +48,7 @@ func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]con
contents, err := ReadFile(usersPath)
if err != nil {
return []config.User{}, err
return nil, err
}
lines := strings.SplitSeq(contents, "\n")
@@ -65,7 +65,7 @@ func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]con
return ParseUsers(usersStr, userAttributes)
}
func ParseUser(userStr string) (config.User, error) {
func ParseUser(userStr string) (*model.LocalUser, error) {
if strings.Contains(userStr, "$$") {
userStr = strings.ReplaceAll(userStr, "$$", "$")
}
@@ -73,27 +73,27 @@ func ParseUser(userStr string) (config.User, error) {
parts := strings.SplitN(userStr, ":", 4)
if len(parts) < 2 || len(parts) > 3 {
return config.User{}, errors.New("invalid user format")
return nil, errors.New("invalid user format")
}
for i, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return config.User{}, errors.New("invalid user format")
return nil, errors.New("invalid user format")
}
parts[i] = trimmed
}
user := config.User{
user := model.LocalUser{
Username: parts[0],
Password: parts[1],
}
if len(parts) == 3 {
user.TotpSecret = parts[2]
user.TOTPSecret = parts[2]
}
return user, nil
return &user, nil
}
func CompileUserEmail(username string, domain string) string {
+47 -47
View File
@@ -4,74 +4,76 @@ import (
"os"
"testing"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"gotest.tools/v3/assert"
)
func TestGetUsers(t *testing.T) {
tmpDir := t.TempDir()
hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"
// Setup
file, err := os.Create("/tmp/tinyauth_users_test.txt")
assert.NilError(t, err)
file, err := os.Create(tmpDir + "/tinyauth_users_test.txt")
require.NoError(t, err)
_, err = file.WriteString(" user1:" + hash + " \n user2:" + hash + " ") // Spacing is on purpose
assert.NilError(t, err)
require.NoError(t, err)
err = file.Close()
assert.NilError(t, err)
defer os.Remove("/tmp/tinyauth_users_test.txt")
require.NoError(t, err)
defer os.Remove(tmpDir + "/tinyauth_users_test.txt")
noAttrs := map[string]config.UserAttributes{}
noAttrs := map[string]model.UserAttributes{}
// 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.NilError(t, err)
assert.NoError(t, err)
assert.NotNil(t, users)
assert.Len(t, *users, 2)
assert.Equal(t, 2, len(users))
assert.Equal(t, "user1", users[0].Username)
assert.Equal(t, hash, users[0].Password)
assert.Equal(t, "user2", users[1].Username)
assert.Equal(t, hash, users[1].Password)
assert.Equal(t, "user1", (*users)[0].Username)
assert.Equal(t, hash, (*users)[0].Password)
assert.Equal(t, "user2", (*users)[1].Username)
assert.Equal(t, hash, (*users)[1].Password)
// Test inline config only
users, err = utils.GetUsers([]string{"user3:" + hash, "user4:" + hash}, "", noAttrs)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, len(users))
assert.Equal(t, "user3", users[0].Username)
assert.Equal(t, "user4", users[1].Username)
assert.Len(t, *users, 2)
assert.Equal(t, "user3", (*users)[0].Username)
assert.Equal(t, "user4", (*users)[1].Username)
// 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.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, 3, len(users))
assert.Len(t, *users, 3)
usernames := map[string]bool{}
for _, u := range users {
for _, u := range *users {
usernames[u.Username] = true
}
assert.Assert(t, usernames["user1"])
assert.Assert(t, usernames["user2"])
assert.Assert(t, usernames["user5"])
assert.True(t, usernames["user1"])
assert.True(t, usernames["user2"])
assert.True(t, usernames["user5"])
// Test attributes applied from userAttributes map
attrs := map[string]config.UserAttributes{
attrs := map[string]model.UserAttributes{
"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.NilError(t, err)
assert.Equal(t, 2, len(users))
assert.NoError(t, err)
assert.Len(t, *users, 2)
for _, u := range users {
for _, u := range *users {
if u.Username == "user1" {
assert.Equal(t, "User One", u.Attributes.Name)
assert.Equal(t, "user1@example.com", u.Attributes.Email)
@@ -84,16 +86,14 @@ func TestGetUsers(t *testing.T) {
// Test empty
users, err = utils.GetUsers([]string{}, "", noAttrs)
assert.NilError(t, err)
assert.Equal(t, 0, len(users))
assert.NoError(t, err)
assert.Nil(t, users)
// 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.Equal(t, 0, len(users))
assert.Nil(t, users)
}
func TestParseUser(t *testing.T) {
@@ -102,38 +102,38 @@ func TestParseUser(t *testing.T) {
// Valid user without TOTP
user, err := utils.ParseUser("user1:" + hash)
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, "user1", user.Username)
assert.Equal(t, hash, user.Password)
assert.Equal(t, "", user.TotpSecret)
assert.Equal(t, "", user.TOTPSecret)
// Valid user with TOTP
user, err = utils.ParseUser("user2:" + hash + ":ABCDEF")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, "user2", user.Username)
assert.Equal(t, hash, user.Password)
assert.Equal(t, "ABCDEF", user.TotpSecret)
assert.Equal(t, "ABCDEF", user.TOTPSecret)
// Valid user with $$ in password
user, err = utils.ParseUser("user3:pa$$word123")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, "user3", user.Username)
assert.Equal(t, "pa$word123", user.Password)
assert.Equal(t, "", user.TotpSecret)
assert.Equal(t, "", user.TOTPSecret)
// User with spaces
user, err = utils.ParseUser(" user4 : password123 : TOTPSECRET ")
assert.NilError(t, err)
assert.NoError(t, err)
assert.Equal(t, "user4", user.Username)
assert.Equal(t, "password123", user.Password)
assert.Equal(t, "TOTPSECRET", user.TotpSecret)
assert.Equal(t, "TOTPSECRET", user.TOTPSecret)
// Invalid users
_, err = utils.ParseUser("user1") // Missing password