mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-01-01 13:02:29 +00:00
Compare commits
7 Commits
refactor/s
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35da7ffc9a | ||
|
|
f1e2b55cd1 | ||
|
|
f564032a11 | ||
|
|
1ec1f82dbd | ||
|
|
7e17a4ad86 | ||
|
|
2dc047d9b7 | ||
|
|
974f2a67f0 |
@@ -55,7 +55,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
|
||||
|
||||
A big thank you to the following people for providing me with more coffee:
|
||||
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <!-- sponsors -->
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <!-- sponsors -->
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.15",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.2.1",
|
||||
"zod": "^4.3.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
@@ -339,9 +339,9 @@
|
||||
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.15", "", {}, "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g=="],
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.15", "", { "dependencies": { "@tanstack/query-core": "5.90.15" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ=="],
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.16", "", { "dependencies": { "@tanstack/query-core": "5.90.16" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
@@ -915,7 +915,7 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
"zod": ["zod@4.3.2", "", {}, "sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.15",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -37,7 +37,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.2.1"
|
||||
"zod": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
|
||||
4
go.mod
4
go.mod
@@ -24,7 +24,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
gotest.tools/v3 v3.5.2
|
||||
modernc.org/sqlite v1.38.2
|
||||
modernc.org/sqlite v1.42.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -119,7 +119,7 @@ require (
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
|
||||
20
go.sum
20
go.sum
@@ -369,18 +369,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -389,8 +389,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -25,6 +25,8 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
|
||||
BaseDN: app.config.Ldap.BaseDN,
|
||||
Insecure: app.config.Ldap.Insecure,
|
||||
SearchFilter: app.config.Ldap.SearchFilter,
|
||||
AuthCert: app.config.Ldap.AuthCert,
|
||||
AuthKey: app.config.Ldap.AuthKey,
|
||||
})
|
||||
|
||||
err := ldapService.Init()
|
||||
|
||||
@@ -67,6 +67,8 @@ type LdapConfig struct {
|
||||
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
|
||||
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
|
||||
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
|
||||
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
|
||||
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"`
|
||||
}
|
||||
|
||||
type ExperimentalConfig struct {
|
||||
|
||||
@@ -43,8 +43,8 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a
|
||||
|
||||
func (controller *ProxyController) SetupRoutes() {
|
||||
proxyGroup := controller.router.Group("/auth")
|
||||
proxyGroup.GET("/:proxy", controller.proxyHandler)
|
||||
proxyGroup.POST("/:proxy", controller.proxyHandler)
|
||||
// There is a later check to control allowed methods per proxy
|
||||
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
||||
}
|
||||
|
||||
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
@@ -69,6 +69,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only allow GET for non-envoy proxies.
|
||||
// Envoy uses the original client method for the external auth request
|
||||
// so we allow Any standard HTTP method for /api/auth/envoy
|
||||
if req.Proxy != "envoy" && c.Request.Method != http.MethodGet {
|
||||
log.Warn().Str("method", c.Request.Method).Msg("Invalid method for proxy")
|
||||
c.Header("Allow", "GET")
|
||||
c.JSON(405, gin.H{
|
||||
"status": 405,
|
||||
"message": "Method Not Allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
|
||||
|
||||
if isBrowser {
|
||||
|
||||
@@ -85,6 +85,14 @@ func TestProxyHandler(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 400, recorder.Code)
|
||||
|
||||
// Test invalid method for non-envoy proxy
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", "/api/auth/traefik", nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 405, recorder.Code)
|
||||
assert.Equal(t, "GET", recorder.Header().Get("Allow"))
|
||||
|
||||
// Test logged out user (traefik/caddy)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/api/auth/traefik", nil)
|
||||
@@ -97,7 +105,7 @@ func TestProxyHandler(t *testing.T) {
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
||||
|
||||
// Test logged out user (envoy)
|
||||
// Test logged out user (envoy - POST method)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", "/api/auth/envoy", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
@@ -109,6 +117,18 @@ func TestProxyHandler(t *testing.T) {
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
||||
|
||||
// Test logged out user (envoy - DELETE method)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("DELETE", "/api/auth/envoy", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
||||
|
||||
// Test logged out user (nginx)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/api/auth/nginx", nil)
|
||||
|
||||
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
@@ -60,23 +61,17 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
log.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||
|
||||
rateIdentifier := req.Username
|
||||
|
||||
if rateIdentifier == "" {
|
||||
rateIdentifier = clientIP
|
||||
}
|
||||
|
||||
log.Debug().Str("username", req.Username).Str("ip", clientIP).Msg("Login attempt")
|
||||
|
||||
isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier)
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
||||
|
||||
if isLocked {
|
||||
log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed login attempts")
|
||||
log.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
"status": 429,
|
||||
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
|
||||
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -84,8 +79,8 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
userSearch := controller.auth.SearchUser(req.Username)
|
||||
|
||||
if userSearch.Type == "unknown" {
|
||||
log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("User not found")
|
||||
controller.auth.RecordLoginAttempt(rateIdentifier, false)
|
||||
log.Warn().Str("username", req.Username).Msg("User not found")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -94,8 +89,8 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !controller.auth.VerifyUser(userSearch, req.Password) {
|
||||
log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Invalid password")
|
||||
controller.auth.RecordLoginAttempt(rateIdentifier, false)
|
||||
log.Warn().Str("username", req.Username).Msg("Invalid password")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -103,9 +98,9 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("username", req.Username).Str("ip", clientIP).Msg("Login successful")
|
||||
log.Info().Str("username", req.Username).Msg("Login successful")
|
||||
|
||||
controller.auth.RecordLoginAttempt(rateIdentifier, true)
|
||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||
|
||||
if userSearch.Type == "local" {
|
||||
user := controller.auth.GetLocalUser(userSearch.Username)
|
||||
@@ -209,23 +204,17 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
log.Debug().Str("username", context.Username).Msg("TOTP verification attempt")
|
||||
|
||||
rateIdentifier := context.Username
|
||||
|
||||
if rateIdentifier == "" {
|
||||
rateIdentifier = clientIP
|
||||
}
|
||||
|
||||
log.Debug().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification attempt")
|
||||
|
||||
isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier)
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(context.Username)
|
||||
|
||||
if isLocked {
|
||||
log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed TOTP attempts")
|
||||
log.Warn().Str("username", context.Username).Msg("Account is locked due to too many failed TOTP attempts")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
"status": 429,
|
||||
"message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remainingTime),
|
||||
"message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remaining),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -235,8 +224,8 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
ok := totp.Validate(req.Code, user.TotpSecret)
|
||||
|
||||
if !ok {
|
||||
log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Invalid TOTP code")
|
||||
controller.auth.RecordLoginAttempt(rateIdentifier, false)
|
||||
log.Warn().Str("username", context.Username).Msg("Invalid TOTP code")
|
||||
controller.auth.RecordLoginAttempt(context.Username, false)
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -244,9 +233,9 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification successful")
|
||||
log.Info().Str("username", context.Username).Msg("TOTP verification successful")
|
||||
|
||||
controller.auth.RecordLoginAttempt(rateIdentifier, true)
|
||||
controller.auth.RecordLoginAttempt(context.Username, true)
|
||||
|
||||
sessionCookie := config.SessionCookie{
|
||||
Username: user.Username,
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
@@ -116,20 +117,34 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
locked, remaining := m.auth.IsAccountLocked(basic.Username)
|
||||
|
||||
if locked {
|
||||
log.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", basic.Username, remaining)
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userSearch := m.auth.SearchUser(basic.Username)
|
||||
|
||||
if userSearch.Type == "unknown" || userSearch.Type == "error" {
|
||||
m.auth.RecordLoginAttempt(basic.Username, false)
|
||||
log.Debug().Msg("User from basic auth not found")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !m.auth.VerifyUser(userSearch, basic.Password) {
|
||||
m.auth.RecordLoginAttempt(basic.Username, false)
|
||||
log.Debug().Msg("Invalid password for basic auth user")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
m.auth.RecordLoginAttempt(basic.Username, true)
|
||||
|
||||
switch userSearch.Type {
|
||||
case "local":
|
||||
log.Debug().Msg("Basic auth user is local")
|
||||
|
||||
@@ -101,7 +101,7 @@ func (auth *AuthService) VerifyUser(search config.UserSearch, password string) b
|
||||
return false
|
||||
}
|
||||
|
||||
err = auth.ldap.Bind(auth.ldap.Config.BindDN, auth.ldap.Config.BindPassword)
|
||||
err = auth.ldap.BindService(true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
|
||||
return false
|
||||
|
||||
@@ -19,21 +19,44 @@ type LdapServiceConfig struct {
|
||||
BaseDN string
|
||||
Insecure bool
|
||||
SearchFilter string
|
||||
AuthCert string
|
||||
AuthKey string
|
||||
}
|
||||
|
||||
type LdapService struct {
|
||||
Config LdapServiceConfig // exported so as the auth service can use it
|
||||
config LdapServiceConfig
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
}
|
||||
|
||||
func NewLdapService(config LdapServiceConfig) *LdapService {
|
||||
return &LdapService{
|
||||
Config: config,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Init() error {
|
||||
// Check whether authentication with client certificate is possible
|
||||
if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
||||
}
|
||||
ldap.cert = &cert
|
||||
log.Info().Msg("Using LDAP with mTLS authentication")
|
||||
|
||||
// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`
|
||||
/*
|
||||
caCert, _ := ioutil.ReadFile(*caFile)
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
tlsConfig := &tls.Config{
|
||||
...
|
||||
RootCAs: caCertPool,
|
||||
}
|
||||
*/
|
||||
}
|
||||
_, err := ldap.connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||
@@ -60,31 +83,46 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
ldap.mutex.Lock()
|
||||
defer ldap.mutex.Unlock()
|
||||
|
||||
conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: ldap.Config.Insecure,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}))
|
||||
var conn *ldapgo.Conn
|
||||
var err error
|
||||
|
||||
// TODO: There's also STARTTLS (or SASL)-based mTLS authentication
|
||||
// scenario, where we first connect to plain text port (389) and
|
||||
// continue with a STARTTLS negotiation:
|
||||
// 1. conn = ldap.DialURL("ldap://ldap.example.com:389")
|
||||
// 2. conn.StartTLS(tlsConfig)
|
||||
// 3. conn.externalBind()
|
||||
if ldap.cert != nil {
|
||||
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{*ldap.cert},
|
||||
}))
|
||||
} else {
|
||||
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: ldap.config.Insecure,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = conn.Bind(ldap.Config.BindDN, ldap.Config.BindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set and return the connection
|
||||
ldap.conn = conn
|
||||
return conn, nil
|
||||
|
||||
err = ldap.BindService(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ldap.conn, nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Search(username string) (string, error) {
|
||||
// Escape the username to prevent LDAP injection
|
||||
escapedUsername := ldapgo.EscapeFilter(username)
|
||||
filter := fmt.Sprintf(ldap.Config.SearchFilter, escapedUsername)
|
||||
filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername)
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
ldap.Config.BaseDN,
|
||||
ldap.config.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
@@ -107,6 +145,19 @@ func (ldap *LdapService) Search(username string) (string, error) {
|
||||
return userDN, nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) BindService(rebind bool) error {
|
||||
// Locks must not be used for initial binding attempt
|
||||
if rebind {
|
||||
ldap.mutex.Lock()
|
||||
defer ldap.mutex.Unlock()
|
||||
}
|
||||
|
||||
if ldap.cert != nil {
|
||||
return ldap.conn.ExternalBind()
|
||||
}
|
||||
return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||
ldap.mutex.Lock()
|
||||
defer ldap.mutex.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user