Compare commits

..

11 Commits

Author SHA1 Message Date
Stavros
dc3b2bc83e refactor: bot suggestions 2025-07-05 15:37:48 +03:00
Stavros
c671ef13b8 fix: fix error message in ldap search result 2025-07-05 15:23:24 +03:00
Stavros
01042a3003 feat: add configurable search filter 2025-07-05 15:22:09 +03:00
Stavros
0e43c50ac0 test: fix tests 2025-07-05 14:46:56 +03:00
Stavros
3fe17cb4ec fix: recognize ldap as a username provider 2025-07-05 03:02:37 +03:00
Stavros
e55f29ccf9 feat: add insecure option for self-signed certificates 2025-07-05 02:53:16 +03:00
Stavros
1e413e671f feat: add ldap support 2025-07-05 02:20:12 +03:00
dependabot[bot]
4524e3322c chore(deps): bump oven/bun from 1.2.17-alpine to 1.2.18-alpine (#229)
Bumps oven/bun from 1.2.17-alpine to 1.2.18-alpine.

---
updated-dependencies:
- dependency-name: oven/bun
  dependency-version: 1.2.18-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-04 12:05:46 +03:00
Stavros
1941de1125 refactor: remove init functions from methods (#228) 2025-07-04 02:35:09 +03:00
Stavros
49c4c7a455 feat: add codeconv to ci 2025-07-04 01:48:38 +03:00
Stavros
c10bff55de fix: encrypt the cookie in sessions (#225)
* fix: encrypt the cookie in sessions

* tests: use new auth config in tests

* fix: coderabbit suggestions
2025-07-04 01:43:36 +03:00
18 changed files with 495 additions and 259 deletions

View File

@@ -39,4 +39,9 @@ jobs:
cp -r frontend/dist internal/assets/dist
- name: Run tests
run: go test -v ./...
run: go test -coverprofile=coverage.txt -v ./...
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -1,5 +1,5 @@
# Site builder
FROM oven/bun:1.2.17-alpine AS frontend-builder
FROM oven/bun:1.2.18-alpine AS frontend-builder
WORKDIR /frontend

View File

@@ -8,13 +8,14 @@ import (
"time"
totpCmd "tinyauth/cmd/totp"
userCmd "tinyauth/cmd/user"
"tinyauth/internal/api"
"tinyauth/internal/auth"
"tinyauth/internal/constants"
"tinyauth/internal/docker"
"tinyauth/internal/handlers"
"tinyauth/internal/hooks"
"tinyauth/internal/ldap"
"tinyauth/internal/providers"
"tinyauth/internal/server"
"tinyauth/internal/types"
"tinyauth/internal/utils"
@@ -58,10 +59,6 @@ var rootCmd = &cobra.Command{
users, err := utils.GetUsers(config.Users, config.UsersFile)
HandleError(err, "Failed to parse users")
if len(users) == 0 && !utils.OAuthConfigured(config) {
HandleError(errors.New("no users or OAuth configured"), "No users or OAuth configured")
}
// Get domain
log.Debug().Msg("Getting domain")
domain, err := utils.GetUpperDomain(config.AppURL)
@@ -114,8 +111,8 @@ var rootCmd = &cobra.Command{
RedirectCookieName: redirectCookieName,
}
// Create api config
apiConfig := types.APIConfig{
// Create server config
serverConfig := types.ServerConfig{
Port: config.Port,
Address: config.Address,
}
@@ -140,36 +137,55 @@ var rootCmd = &cobra.Command{
}
// Create docker service
docker := docker.NewDocker()
// Initialize docker
err = docker.Init()
docker, err := docker.NewDocker()
HandleError(err, "Failed to initialize docker")
// Create LDAP service if configured
var ldapService *ldap.LDAP
if config.LdapAddress != "" {
log.Info().Msg("Using LDAP for authentication")
ldapConfig := types.LdapConfig{
Address: config.LdapAddress,
BindDN: config.LdapBindDN,
BindPassword: config.LdapBindPassword,
BaseDN: config.LdapBaseDN,
Insecure: config.LdapInsecure,
SearchFilter: config.LdapSearchFilter,
}
// Create LDAP service
ldapService, err = ldap.NewLDAP(ldapConfig)
HandleError(err, "Failed to create LDAP service")
} else {
log.Info().Msg("LDAP not configured, using local users or OAuth")
}
// Check if we have any users configured
if len(users) == 0 && !utils.OAuthConfigured(config) && ldapService == nil {
HandleError(errors.New("err no users"), "Unable to find a source of users")
}
// Create auth service
auth := auth.NewAuth(authConfig, docker)
auth := auth.NewAuth(authConfig, docker, ldapService)
// Create OAuth providers service
providers := providers.NewProviders(oauthConfig)
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(hooksConfig, auth, providers)
// Create handlers
handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker)
// Create API
api := api.NewAPI(apiConfig, handlers)
// Create server
srv, err := server.NewServer(serverConfig, handlers)
HandleError(err, "Failed to create server")
// Setup routes
api.Init()
api.SetupRoutes()
// Start
api.Run()
// Start server
err = srv.Start()
HandleError(err, "Failed to start server")
},
}
@@ -229,6 +245,12 @@ func init() {
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
rootCmd.Flags().String("forgot-password-message", "You can reset your password by changing the `USERS` environment variable.", "Message to show on the forgot password page.")
rootCmd.Flags().String("background-image", "/background.jpg", "Background image URL for the login page.")
rootCmd.Flags().String("ldap-address", "", "LDAP server address (e.g. ldap://localhost:389).")
rootCmd.Flags().String("ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com).")
rootCmd.Flags().String("ldap-bind-password", "", "LDAP bind password.")
rootCmd.Flags().String("ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com).")
rootCmd.Flags().Bool("ldap-insecure", false, "Skip certificate verification for the LDAP server.")
rootCmd.Flags().String("ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup.")
// Bind flags to environment
viper.BindEnv("port", "PORT")
@@ -264,6 +286,12 @@ func init() {
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE")
viper.BindEnv("background-image", "BACKGROUND_IMAGE")
viper.BindEnv("ldap-address", "LDAP_ADDRESS")
viper.BindEnv("ldap-bind-dn", "LDAP_BIND_DN")
viper.BindEnv("ldap-bind-password", "LDAP_BIND_PASSWORD")
viper.BindEnv("ldap-base-dn", "LDAP_BASE_DN")
viper.BindEnv("ldap-insecure", "LDAP_INSECURE")
viper.BindEnv("ldap-search-filter", "LDAP_SEARCH_FILTER")
// Bind flags to viper
viper.BindPFlags(rootCmd.Flags())

View File

@@ -162,9 +162,9 @@ export const LoginPage = () => {
/>
)}
{configuredProviders.length == 0 && (
<h3 className="text-center text-xl text-red-600">
<p className="text-center text-red-600 max-w-sm">
{t("failedToFetchProvidersTitle")}
</h3>
</p>
)}
</CardContent>
</Card>

3
go.mod
View File

@@ -16,11 +16,13 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -60,6 +62,7 @@ require (
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect

22
go.sum
View File

@@ -1,9 +1,13 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -90,6 +94,10 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -126,8 +134,22 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"tinyauth/internal/docker"
"tinyauth/internal/ldap"
"tinyauth/internal/types"
"tinyauth/internal/utils"
@@ -16,36 +17,40 @@ import (
"golang.org/x/crypto/bcrypt"
)
func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
return &Auth{
Config: config,
Docker: docker,
LoginAttempts: make(map[string]*types.LoginAttempt),
}
}
type Auth struct {
Config types.AuthConfig
Docker *docker.Docker
LoginAttempts map[string]*types.LoginAttempt
LoginMutex sync.RWMutex
Store *sessions.CookieStore
LDAP *ldap.LDAP
}
func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
func NewAuth(config types.AuthConfig, docker *docker.Docker, ldap *ldap.LDAP) *Auth {
// Create cookie store
store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
store := sessions.NewCookieStore([]byte(config.HMACSecret), []byte(config.EncryptionSecret))
// Configure cookie store
store.Options = &sessions.Options{
Path: "/",
MaxAge: auth.Config.SessionExpiry,
Secure: auth.Config.CookieSecure,
MaxAge: config.SessionExpiry,
Secure: config.CookieSecure,
HttpOnly: true,
Domain: fmt.Sprintf(".%s", auth.Config.Domain),
Domain: fmt.Sprintf(".%s", config.Domain),
}
return &Auth{
Config: config,
Docker: docker,
LoginAttempts: make(map[string]*types.LoginAttempt),
Store: store,
LDAP: ldap,
}
}
func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
// Get session
session, err := store.Get(c.Request, auth.Config.SessionCookieName)
session, err := auth.Store.Get(c.Request, auth.Config.SessionCookieName)
if err != nil {
log.Warn().Err(err).Msg("Invalid session, clearing cookie and retrying")
@@ -54,7 +59,7 @@ func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.CookieSecure, true)
// Try to get the session again
session, err = store.Get(c.Request, auth.Config.SessionCookieName)
session, err = auth.Store.Get(c.Request, auth.Config.SessionCookieName)
if err != nil {
// If we still can't get the session, log the error and return nil
@@ -66,14 +71,97 @@ func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
return session, nil
}
func (auth *Auth) GetUser(username string) *types.User {
func (auth *Auth) SearchUser(username string) types.UserSearch {
// Loop through users and return the user if the username matches
for _, user := range auth.Config.Users {
if user.Username == username {
return &user
log.Debug().Str("username", username).Msg("Searching for user")
if auth.GetLocalUser(username).Username != "" {
log.Debug().Str("username", username).Msg("Found local user")
// If user found, return a user with the username and type "local"
return types.UserSearch{
Username: username,
Type: "local",
}
}
return nil
// If no user found, check LDAP
if auth.LDAP != nil {
log.Debug().Str("username", username).Msg("Checking LDAP for user")
userDN, err := auth.LDAP.Search(username)
if err != nil {
log.Warn().Err(err).Str("username", username).Msg("Failed to find user in LDAP")
return types.UserSearch{}
}
// If user found in LDAP, return a user with the DN as username
return types.UserSearch{
Username: userDN,
Type: "ldap",
}
}
return types.UserSearch{}
}
func (auth *Auth) VerifyUser(search types.UserSearch, password string) bool {
// Authenticate the user based on the type
switch search.Type {
case "local":
// Get local user
user := auth.GetLocalUser(search.Username)
// Check if password is correct
return auth.CheckPassword(user, password)
case "ldap":
// If LDAP is configured, bind to the LDAP server with the user DN and password
if auth.LDAP != nil {
log.Debug().Str("username", search.Username).Msg("Binding to LDAP for user authentication")
// Bind to the LDAP server
err := auth.LDAP.Bind(search.Username, password)
if err != nil {
log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
return false
}
// If bind is successful, rebind with the LDAP bind user
err = auth.LDAP.Bind(auth.LDAP.Config.BindDN, auth.LDAP.Config.BindPassword)
if err != nil {
log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
// Consider closing the connection or creating a new one
return false
}
log.Debug().Str("username", search.Username).Msg("LDAP authentication successful")
// Return true if the bind was successful
return true
}
default:
log.Warn().Str("type", search.Type).Msg("Unknown user type for authentication")
return false
}
// If no user found or authentication failed, return false
log.Warn().Str("username", search.Username).Msg("User authentication failed")
return false
}
func (auth *Auth) GetLocalUser(username string) types.User {
// Loop through users and return the user if the username matches
log.Debug().Str("username", username).Msg("Searching for local user")
for _, user := range auth.Config.Users {
if user.Username == username {
return user
}
}
// If no user found, return an empty user
log.Warn().Str("username", username).Msg("Local user not found")
return types.User{}
}
func (auth *Auth) CheckPassword(user types.User, password string) bool {
@@ -273,7 +361,7 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error)
func (auth *Auth) UserAuthConfigured() bool {
// If there are users, return true
return len(auth.Config.Users) > 0
return len(auth.Config.Users) > 0 || auth.LDAP != nil
}
func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext, labels types.Labels) bool {

View File

@@ -18,7 +18,7 @@ func TestLoginRateLimiting(t *testing.T) {
// Initialize a new auth service with 3 max retries and 5 seconds timeout
config.LoginMaxRetries = 3
config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{})
authService := auth.NewAuth(config, &docker.Docker{}, nil)
// Test identifier
identifier := "test_user"
@@ -62,7 +62,7 @@ func TestLoginRateLimiting(t *testing.T) {
// Reinitialize auth service with a shorter timeout for testing
config.LoginTimeout = 1
config.LoginMaxRetries = 3
authService = auth.NewAuth(config, &docker.Docker{})
authService = auth.NewAuth(config, &docker.Docker{}, nil)
// Add enough failed attempts to lock the account
for i := 0; i < 3; i++ {
@@ -87,7 +87,7 @@ func TestLoginRateLimiting(t *testing.T) {
t.Log("Testing disabled rate limiting")
config.LoginMaxRetries = 0
config.LoginTimeout = 0
authService = auth.NewAuth(config, &docker.Docker{})
authService = auth.NewAuth(config, &docker.Docker{}, nil)
for i := 0; i < 10; i++ {
authService.RecordLoginAttempt(identifier, false)
@@ -103,7 +103,7 @@ func TestConcurrentLoginAttempts(t *testing.T) {
// Initialize a new auth service with 2 max retries and 5 seconds timeout
config.LoginMaxRetries = 2
config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{})
authService := auth.NewAuth(config, &docker.Docker{}, nil)
// Test multiple identifiers
identifiers := []string{"user1", "user2", "user3"}

View File

@@ -11,35 +11,30 @@ import (
"github.com/rs/zerolog/log"
)
func NewDocker() *Docker {
return &Docker{}
}
type Docker struct {
Client *client.Client
Context context.Context
}
func (docker *Docker) Init() error {
func NewDocker() (*Docker, error) {
// Create a new docker client
client, err := client.NewClientWithOpts(client.FromEnv)
// Check if there was an error
if err != nil {
return err
return nil, err
}
// Create the context
docker.Context = context.Background()
ctx := context.Background()
// Negotiate API version
client.NegotiateAPIVersion(docker.Context)
client.NegotiateAPIVersion(ctx)
// Set client
docker.Client = client
// Done
return nil
return &Docker{
Client: client,
Context: ctx,
}, nil
}
func (docker *Docker) GetContainers() ([]container.Summary, error) {

View File

@@ -18,6 +18,14 @@ import (
"github.com/rs/zerolog/log"
)
type Handlers struct {
Config types.HandlersConfig
Auth *auth.Auth
Hooks *hooks.Hooks
Providers *providers.Providers
Docker *docker.Docker
}
func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers {
return &Handlers{
Config: config,
@@ -28,14 +36,6 @@ func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hook
}
}
type Handlers struct {
Config types.HandlersConfig
Auth *auth.Auth
Hooks *hooks.Hooks
Providers *providers.Providers
Docker *docker.Docker
}
func (h *Handlers) AuthHandler(c *gin.Context) {
// Create struct for proxy
var proxy types.Proxy
@@ -362,11 +362,13 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
return
}
// Get user based on username
user := h.Auth.GetUser(login.Username)
// Search for a user based on username
userSearch := h.Auth.SearchUser(login.Username)
log.Debug().Interface("userSearch", userSearch).Msg("Searching for user")
// User does not exist
if user == nil {
if userSearch.Type == "" {
log.Debug().Str("username", login.Username).Msg("User not found")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
@@ -380,7 +382,7 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
log.Debug().Msg("Got user")
// Check if password is correct
if !h.Auth.CheckPassword(*user, login.Password) {
if !h.Auth.VerifyUser(userSearch, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
@@ -396,28 +398,34 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
// Record successful login attempt (will reset failed attempt counter)
h.Auth.RecordLoginAttempt(rateIdentifier, true)
// Check if user has totp enabled
if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Check if user is using TOTP
if userSearch.Type == "local" {
// Get local user
localUser := h.Auth.GetLocalUser(login.Username)
// Set totp pending cookie
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Name: utils.Capitalize(login.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain),
Provider: "username",
TotpPending: true,
})
// Check if TOTP is enabled
if localUser.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
// Set totp pending cookie
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Name: utils.Capitalize(login.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain),
Provider: "username",
TotpPending: true,
})
// Stop further processing
return
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
// Stop further processing
return
}
}
// Create session cookie with username as provider
@@ -469,17 +477,7 @@ func (h *Handlers) TotpHandler(c *gin.Context) {
}
// Get user
user := h.Auth.GetUser(userContext.Username)
// Check if user exists
if user == nil {
log.Debug().Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
user := h.Auth.GetLocalUser(userContext.Username)
// Check if totp is correct
ok := totp.Validate(totpReq.Code, user.TotpSecret)

View File

@@ -12,6 +12,12 @@ import (
"github.com/rs/zerolog/log"
)
type Hooks struct {
Config types.HooksConfig
Auth *auth.Auth
Providers *providers.Providers
}
func NewHooks(config types.HooksConfig, auth *auth.Auth, providers *providers.Providers) *Hooks {
return &Hooks{
Config: config,
@@ -20,12 +26,6 @@ func NewHooks(config types.HooksConfig, auth *auth.Auth, providers *providers.Pr
}
}
type Hooks struct {
Config types.HooksConfig
Auth *auth.Auth
Providers *providers.Providers
}
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Get session cookie and basic auth
cookie, err := hooks.Auth.GetSessionCookie(c)
@@ -35,30 +35,49 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
if basic != nil {
log.Debug().Msg("Got basic auth")
// Get user
user := hooks.Auth.GetUser(basic.Username)
// Search for a user based on username
userSearch := hooks.Auth.SearchUser(basic.Username)
// Check we have a user
if user == nil {
if userSearch.Type == "" {
log.Error().Str("username", basic.Username).Msg("User does not exist")
// Return empty context
return types.UserContext{}
}
// Check if the user has a correct password
if hooks.Auth.CheckPassword(*user, basic.Password) {
// Return user context since we are logged in with basic auth
// Verify the user
if !hooks.Auth.VerifyUser(userSearch, basic.Password) {
log.Error().Str("username", basic.Username).Msg("Password incorrect")
// Return empty context
return types.UserContext{}
}
// Get the user type
if userSearch.Type == "ldap" {
log.Debug().Msg("User is LDAP")
return types.UserContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain),
IsLoggedIn: true,
Provider: "basic",
TotpEnabled: user.TotpSecret != "",
TotpEnabled: false,
}
}
user := hooks.Auth.GetLocalUser(basic.Username)
return types.UserContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain),
IsLoggedIn: true,
Provider: "basic",
TotpEnabled: user.TotpSecret != "",
}
}
// Check cookie error after basic auth
@@ -85,18 +104,25 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
if cookie.Provider == "username" {
log.Debug().Msg("Provider is username")
// Check if user exists
if hooks.Auth.GetUser(cookie.Username) != nil {
log.Debug().Msg("User exists")
// Search for the user with the username
userSearch := hooks.Auth.SearchUser(cookie.Username)
// It exists so we are logged in
return types.UserContext{
Username: cookie.Username,
Name: cookie.Name,
Email: cookie.Email,
IsLoggedIn: true,
Provider: "username",
}
if userSearch.Type == "" {
log.Error().Str("username", cookie.Username).Msg("User does not exist")
// Return empty context
return types.UserContext{}
}
log.Debug().Str("type", userSearch.Type).Msg("User exists")
// It exists so we are logged in
return types.UserContext{
Username: cookie.Username,
Name: cookie.Name,
Email: cookie.Email,
IsLoggedIn: true,
Provider: "username",
}
}

77
internal/ldap/ldap.go Normal file
View File

@@ -0,0 +1,77 @@
package ldap
import (
"crypto/tls"
"fmt"
"tinyauth/internal/types"
ldapgo "github.com/go-ldap/ldap/v3"
)
type LDAP struct {
Config types.LdapConfig
Conn *ldapgo.Conn
BaseDN string
}
func NewLDAP(config types.LdapConfig) (*LDAP, error) {
// Connect to the LDAP server
conn, err := ldapgo.DialURL(config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: config.Insecure,
MinVersion: tls.VersionTLS12,
}))
if err != nil {
return nil, err
}
// Bind to the LDAP server with the provided credentials
err = conn.Bind(config.BindDN, config.BindPassword)
if err != nil {
return nil, err
}
return &LDAP{
Config: config,
Conn: conn,
BaseDN: config.BaseDN,
}, nil
}
func (l *LDAP) Search(username string) (string, error) {
// Escape the username to prevent LDAP injection
escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(l.Config.SearchFilter, escapedUsername)
// Create a search request to find the user by username
searchRequest := ldapgo.NewSearchRequest(
l.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter,
[]string{"dn"},
nil,
)
// Perform the search
searchResult, err := l.Conn.Search(searchRequest)
if err != nil {
return "", err
}
if len(searchResult.Entries) != 1 {
return "", fmt.Errorf("err multiple or no entries found for user %s", username)
}
// User found, return the distinguished name (DN)
userDN := searchResult.Entries[0].DN
return userDN, nil
}
func (l *LDAP) Bind(userDN string, password string) error {
// Bind to the LDAP server with the user's DN and password
err := l.Conn.Bind(userDN, password)
if err != nil {
return err
}
return nil
}

View File

@@ -10,32 +10,24 @@ import (
"golang.org/x/oauth2"
)
func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth {
return &OAuth{
Config: config,
InsecureSkipVerify: insecureSkipVerify,
}
}
type OAuth struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
InsecureSkipVerify bool
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
}
func (oauth *OAuth) Init() {
func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth {
// Create transport with TLS
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: oauth.InsecureSkipVerify,
InsecureSkipVerify: insecureSkipVerify,
MinVersion: tls.VersionTLS12,
},
}
// Create a new context
oauth.Context = context.Background()
ctx := context.Background()
// Create the HTTP client with the transport
httpClient := &http.Client{
@@ -43,9 +35,16 @@ func (oauth *OAuth) Init() {
}
// Set the HTTP client in the context
oauth.Context = context.WithValue(oauth.Context, oauth2.HTTPClient, httpClient)
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Create the verifier
oauth.Verifier = oauth2.GenerateVerifier()
verifier := oauth2.GenerateVerifier()
return &OAuth{
Config: config,
Context: ctx,
Verifier: verifier,
}
}
func (oauth *OAuth) GetAuthURL(state string) string {

View File

@@ -11,12 +11,6 @@ import (
"golang.org/x/oauth2/endpoints"
)
func NewProviders(config types.OAuthConfig) *Providers {
return &Providers{
Config: config,
}
}
type Providers struct {
Config types.OAuthConfig
Github *oauth.OAuth
@@ -24,60 +18,57 @@ type Providers struct {
Generic *oauth.OAuth
}
func (providers *Providers) Init() {
func NewProviders(config types.OAuthConfig) *Providers {
providers := &Providers{
Config: config,
}
// If we have a client id and secret for github, initialize the oauth provider
if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" {
if config.GithubClientId != "" && config.GithubClientSecret != "" {
log.Info().Msg("Initializing Github OAuth")
// Create a new oauth provider with the github config
providers.Github = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GithubClientId,
ClientSecret: providers.Config.GithubClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL),
ClientID: config.GithubClientId,
ClientSecret: config.GithubClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", config.AppURL),
Scopes: GithubScopes(),
Endpoint: endpoints.GitHub,
}, false)
// Initialize the oauth provider
providers.Github.Init()
}
// If we have a client id and secret for google, initialize the oauth provider
if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" {
if config.GoogleClientId != "" && config.GoogleClientSecret != "" {
log.Info().Msg("Initializing Google OAuth")
// Create a new oauth provider with the google config
providers.Google = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GoogleClientId,
ClientSecret: providers.Config.GoogleClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL),
ClientID: config.GoogleClientId,
ClientSecret: config.GoogleClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", config.AppURL),
Scopes: GoogleScopes(),
Endpoint: endpoints.Google,
}, false)
// Initialize the oauth provider
providers.Google.Init()
}
// If we have a client id and secret for generic oauth, initialize the oauth provider
if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" {
if config.GenericClientId != "" && config.GenericClientSecret != "" {
log.Info().Msg("Initializing Generic OAuth")
// Create a new oauth provider with the generic config
providers.Generic = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GenericClientId,
ClientSecret: providers.Config.GenericClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL),
Scopes: providers.Config.GenericScopes,
ClientID: config.GenericClientId,
ClientSecret: config.GenericClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", config.AppURL),
Scopes: config.GenericScopes,
Endpoint: oauth2.Endpoint{
AuthURL: providers.Config.GenericAuthURL,
TokenURL: providers.Config.GenericTokenURL,
AuthURL: config.GenericAuthURL,
TokenURL: config.GenericTokenURL,
},
}, providers.Config.GenericSkipSSL)
// Initialize the oauth provider
providers.Generic.Init()
}, config.GenericSkipSSL)
}
return providers
}
func (providers *Providers) GetProvider(provider string) *oauth.OAuth {

View File

@@ -1,4 +1,4 @@
package api
package server
import (
"fmt"
@@ -15,20 +15,13 @@ import (
"github.com/rs/zerolog/log"
)
func NewAPI(config types.APIConfig, handlers *handlers.Handlers) *API {
return &API{
Config: config,
Handlers: handlers,
}
}
type API struct {
Config types.APIConfig
Router *gin.Engine
type Server struct {
Config types.ServerConfig
Handlers *handlers.Handlers
Router *gin.Engine
}
func (api *API) Init() {
func NewServer(config types.ServerConfig, handlers *handlers.Handlers) (*Server, error) {
// Disable gin logs
gin.SetMode(gin.ReleaseMode)
@@ -42,7 +35,7 @@ func (api *API) Init() {
dist, err := fs.Sub(assets.Assets, "dist")
if err != nil {
log.Fatal().Err(err).Msg("Failed to get UI assets")
return nil, err
}
// Create file server
@@ -69,41 +62,38 @@ func (api *API) Init() {
}
})
// Set router
api.Router = router
// Proxy routes
router.GET("/api/auth/:proxy", handlers.AuthHandler)
// Auth routes
router.POST("/api/login", handlers.LoginHandler)
router.POST("/api/totp", handlers.TotpHandler)
router.POST("/api/logout", handlers.LogoutHandler)
// Context routes
router.GET("/api/app", handlers.AppHandler)
router.GET("/api/user", handlers.UserHandler)
// OAuth routes
router.GET("/api/oauth/url/:provider", handlers.OauthUrlHandler)
router.GET("/api/oauth/callback/:provider", handlers.OauthCallbackHandler)
// App routes
router.GET("/api/healthcheck", handlers.HealthcheckHandler)
// Return the server
return &Server{
Config: config,
Handlers: handlers,
Router: router,
}, nil
}
func (api *API) SetupRoutes() {
// Proxy
api.Router.GET("/api/auth/:proxy", api.Handlers.AuthHandler)
// Auth
api.Router.POST("/api/login", api.Handlers.LoginHandler)
api.Router.POST("/api/totp", api.Handlers.TotpHandler)
api.Router.POST("/api/logout", api.Handlers.LogoutHandler)
// Context
api.Router.GET("/api/app", api.Handlers.AppHandler)
api.Router.GET("/api/user", api.Handlers.UserHandler)
// OAuth
api.Router.GET("/api/oauth/url/:provider", api.Handlers.OauthUrlHandler)
api.Router.GET("/api/oauth/callback/:provider", api.Handlers.OauthCallbackHandler)
// App
api.Router.GET("/api/healthcheck", api.Handlers.HealthcheckHandler)
}
func (api *API) Run() {
log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server")
func (s *Server) Start() error {
// Run server
err := api.Router.Run(fmt.Sprintf("%s:%d", api.Config.Address, api.Config.Port))
log.Info().Str("address", s.Config.Address).Int("port", s.Config.Port).Msg("Starting server")
// Check for errors
if err != nil {
log.Fatal().Err(err).Msg("Failed to start server")
}
return s.Router.Run(fmt.Sprintf("%s:%d", s.Config.Address, s.Config.Port))
}
// zerolog is a middleware for gin that logs requests using zerolog

View File

@@ -1,4 +1,4 @@
package api_test
package server_test
import (
"encoding/json"
@@ -8,19 +8,19 @@ import (
"reflect"
"strings"
"testing"
"tinyauth/internal/api"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/handlers"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/server"
"tinyauth/internal/types"
"github.com/magiconair/properties/assert"
)
// Simple API config for tests
var apiConfig = types.APIConfig{
// Simple server config for tests
var serverConfig = types.ServerConfig{
Port: 8080,
Address: "0.0.0.0",
}
@@ -68,15 +68,11 @@ var user = types.User{
Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass
}
// We need all this to be able to test the API
func getAPI(t *testing.T) *api.API {
// We need all this to be able to test the server
func getServer(t *testing.T) *server.Server {
// Create docker service
docker := docker.NewDocker()
docker, err := docker.NewDocker()
// Initialize docker
err := docker.Init()
// Check if there was an error
if err != nil {
t.Fatalf("Failed to initialize docker: %v", err)
}
@@ -88,36 +84,34 @@ func getAPI(t *testing.T) *api.API {
Password: user.Password,
},
}
auth := auth.NewAuth(authConfig, docker)
auth := auth.NewAuth(authConfig, docker, nil)
// Create providers service
providers := providers.NewProviders(types.OAuthConfig{})
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(hooksConfig, auth, providers)
// Create handlers service
handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker)
// Create API
api := api.NewAPI(apiConfig, handlers)
// Create server
srv, err := server.NewServer(serverConfig, handlers)
// Setup routes
api.Init()
api.SetupRoutes()
if err != nil {
t.Fatalf("Failed to create server: %v", err)
}
return api
// Return the server
return srv
}
// Test login (we will need this for the other tests)
// Test login
func TestLogin(t *testing.T) {
t.Log("Testing login")
// Get API
api := getAPI(t)
// Get server
api := getServer(t)
// Create recorder
recorder := httptest.NewRecorder()
@@ -162,8 +156,8 @@ func TestLogin(t *testing.T) {
func TestAppContext(t *testing.T) {
t.Log("Testing app context")
// Get API
api := getAPI(t)
// Get server
api := getServer(t)
// Create recorder
recorder := httptest.NewRecorder()
@@ -230,8 +224,8 @@ func TestAppContext(t *testing.T) {
func TestUserContext(t *testing.T) {
t.Log("Testing user context")
// Get API
api := getAPI(t)
// Get server
api := getServer(t)
// Create recorder
recorder := httptest.NewRecorder()
@@ -288,8 +282,8 @@ func TestUserContext(t *testing.T) {
func TestLogout(t *testing.T) {
t.Log("Testing logout")
// Get API
api := getAPI(t)
// Get server
api := getServer(t)
// Create recorder
recorder := httptest.NewRecorder()
@@ -319,5 +313,3 @@ func TestLogout(t *testing.T) {
t.Fatalf("Cookie not flushed")
}
}
// TODO: Testing for the oauth stuff

View File

@@ -36,6 +36,12 @@ type Config struct {
LoginMaxRetries int `mapstructure:"login-max-retries"`
FogotPasswordMessage string `mapstructure:"forgot-password-message" validate:"required"`
BackgroundImage string `mapstructure:"background-image" validate:"required"`
LdapAddress string `mapstructure:"ldap-address"`
LdapBindDN string `mapstructure:"ldap-bind-dn"`
LdapBindPassword string `mapstructure:"ldap-bind-password"`
LdapBaseDN string `mapstructure:"ldap-base-dn"`
LdapInsecure bool `mapstructure:"ldap-insecure"`
LdapSearchFilter string `mapstructure:"ldap-search-filter"`
}
// Server configuration
@@ -69,8 +75,8 @@ type OAuthConfig struct {
AppURL string
}
// APIConfig is the configuration for the API
type APIConfig struct {
// ServerConfig is the configuration for the server
type ServerConfig struct {
Port int
Address string
}
@@ -122,3 +128,13 @@ type Labels struct {
OAuth OAuthLabels
IP IPLabels
}
// Ldap config is a struct that contains the configuration for the LDAP service
type LdapConfig struct {
Address string
BindDN string
BindPassword string
BaseDN string
Insecure bool
SearchFilter string
}

View File

@@ -12,6 +12,12 @@ type User struct {
TotpSecret string
}
// UserSearch is the response of the get user
type UserSearch struct {
Username string
Type string // "local", "ldap" or empty
}
// Users is a list of users
type Users []User