Add allowed domains list.

This commit is contained in:
2024-10-03 14:00:43 +01:00
parent e97d8e15ff
commit 2fbca0a88c
5 changed files with 302 additions and 67 deletions
+4 -2
View File
@@ -4,7 +4,7 @@ type: middleware
import: github.com/lukaszraczylo/traefikoidc import: github.com/lukaszraczylo/traefikoidc
summary: | summary: |
Middleware adding OIDC authentication to traefik routes. Middleware adding OIDC authentication to traefik routes. Does what it says on the tin.
testData: testData:
providerURL: https://accounts.google.com providerURL: https://accounts.google.com
@@ -16,10 +16,12 @@ testData:
- openid - openid
- email - email
- profile - profile
allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no
- raczylo.com
sessionEncryptionKey: potato-secret sessionEncryptionKey: potato-secret
forceHTTPS: false forceHTTPS: false
logLevel: debug # debug, info, warn, error logLevel: debug # debug, info, warn, error
rateLimit: 100 # Simple rate limiter to prevent brute force attacks rateLimit: 100 # Simple rate limiter to prevent brute force attacks
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
- /login - /login # covers /login, /login/me, /login/reminder etc.
- /my-public-data - /my-public-data
+9
View File
@@ -5,6 +5,13 @@ This middleware is supposed to replace the need for the forward-auth and oauth2-
### Configuration options ### Configuration options
Middleware currently supports following scenarios:
* Setting custom callback and logout URLs via `callbackURL` and `logoutURL`
* Allowing for access only from the listed domains if `allowedUserDomains` is set, otherwise it relies entirely on the OIDC provider
* Using excluded URLs which do **NOT** require the OIDC authentication
* Rate limiting requests to prevent the bruteforce attacks
#### Docker compose example #### Docker compose example
`docker-compose.yaml` `docker-compose.yaml`
@@ -94,6 +101,8 @@ http:
- openid - openid
- email - email
- profile - profile
allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no
- raczylo.com
sessionEncryptionKey: potato-secret sessionEncryptionKey: potato-secret
forceHTTPS: false forceHTTPS: false
logLevel: debug # debug, info, warn, error logLevel: debug # debug, info, warn, error
+62 -34
View File
@@ -26,30 +26,31 @@ type JWTVerifier interface {
} }
type TraefikOidc struct { type TraefikOidc struct {
next http.Handler next http.Handler
name string name string
store sessions.Store store sessions.Store
redirURLPath string redirURLPath string
logoutURLPath string logoutURLPath string
issuerURL string issuerURL string
jwkCache *JWKCache jwkCache *JWKCache
tokenBlacklist *TokenBlacklist tokenBlacklist *TokenBlacklist
jwksURL string jwksURL string
clientID string clientID string
clientSecret string clientSecret string
authURL string authURL string
tokenURL string tokenURL string
scopes []string scopes []string
limiter *rate.Limiter limiter *rate.Limiter
forceHTTPS bool forceHTTPS bool
scheme string scheme string
tokenCache *TokenCache tokenCache *TokenCache
httpClient *http.Client httpClient *http.Client
logger *Logger logger *Logger
redirectURL string redirectURL string
tokenVerifier TokenVerifier tokenVerifier TokenVerifier
jwtVerifier JWTVerifier jwtVerifier JWTVerifier
excludedURLs map[string]struct{} excludedURLs map[string]struct{}
allowedUserDomains map[string]struct{}
} }
type ProviderMetadata struct { type ProviderMetadata struct {
@@ -191,6 +192,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
return m return m
}(), }(),
redirectURL: "", redirectURL: "",
allowedUserDomains: func() map[string]struct{} {
m := make(map[string]struct{})
for _, domain := range config.AllowedUserDomains {
m[domain] = struct{}{}
}
return m
}(),
} }
// add defaultExcludedURLs to excludedURLs // add defaultExcludedURLs to excludedURLs
for k, v := range defaultExcludedURLs { for k, v := range defaultExcludedURLs {
@@ -289,28 +297,33 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
if authenticated { if authenticated {
if needsRefresh {
// Attempt to refresh the token silently
if refreshed := t.refreshToken(rw, req, session); !refreshed {
// If refresh failed, re-authenticate
t.initiateAuthentication(rw, req, session, t.redirectURL)
return
}
}
idToken, ok := session.Values["id_token"].(string) idToken, ok := session.Values["id_token"].(string)
if !ok || idToken == "" { if !ok || idToken == "" {
t.logger.Errorf("No id_token found in session")
t.initiateAuthentication(rw, req, session, t.redirectURL)
return return
} }
claims, err := extractClaims(idToken) claims, err := extractClaims(idToken)
if err != nil { if err != nil {
t.logger.Errorf("Failed to extract claims: %v", err) t.logger.Errorf("Failed to extract claims: %v", err)
t.initiateAuthentication(rw, req, session, t.redirectURL)
return return
} }
// Add authenticated user email to the header X-Forwarded-User
email, _ := claims["email"].(string) email, _ := claims["email"].(string)
if email == "" {
t.logger.Errorf("No email found in token claims")
t.initiateAuthentication(rw, req, session, t.redirectURL)
return
}
if !t.isAllowedDomain(email) {
t.logger.Infof("User with email %s is not from an allowed domain", email)
http.Error(rw, "Access denied: Your email domain is not allowed", http.StatusForbidden)
return
}
req.Header.Set("X-Forwarded-User", email) req.Header.Set("X-Forwarded-User", email)
t.next.ServeHTTP(rw, req) t.next.ServeHTTP(rw, req)
@@ -508,3 +521,18 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
return true return true
} }
func (t *TraefikOidc) isAllowedDomain(email string) bool {
if len(t.allowedUserDomains) == 0 {
return true // If no domains are specified, all are allowed
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
return false // Invalid email format
}
domain := parts[1]
_, ok := t.allowedUserDomains[domain]
return ok
}
+226 -31
View File
@@ -120,36 +120,71 @@ func (suite *TraefikOidcTestSuite) SetupTest() {
} }
func (suite *TraefikOidcTestSuite) TestServeHTTP_AuthenticatedUser() { func (suite *TraefikOidcTestSuite) TestServeHTTP_AuthenticatedUser() {
req := httptest.NewRequest("GET", "http://example.com", nil) testCases := []struct {
rw := httptest.NewRecorder() name string
setupClaims func() map[string]interface{}
session := sessions.NewSession(suite.mockStore, cookieName) expectedStatus int
session.Values["authenticated"] = true expectedBody string
expectedHeader string
claims := map[string]interface{}{ }{
"exp": float64(time.Now().Add(time.Hour).Unix()), {
name: "Valid authenticated user",
setupClaims: func() map[string]interface{} {
return map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
"email": "[email protected]",
}
},
expectedStatus: http.StatusOK,
expectedBody: "OK",
expectedHeader: "[email protected]",
},
{
name: "Authenticated user without email",
setupClaims: func() map[string]interface{} {
return map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
},
expectedStatus: http.StatusFound,
expectedBody: "",
expectedHeader: "",
},
} }
claimsJSON, _ := json.Marshal(claims)
encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON)
mockToken := fmt.Sprintf("header.%s.signature", encodedClaims)
session.Values["id_token"] = mockToken
suite.mockStore.On("Get", req, cookieName).Return(session, nil) for _, tc := range testCases {
suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.Run(tc.name, func() {
req := httptest.NewRequest("GET", "http://example.com", nil)
rw := httptest.NewRecorder()
suite.mockTokenVerifier.On("VerifyToken", mockToken).Return(nil) session := sessions.NewSession(suite.mockStore, cookieName)
session.Values["authenticated"] = true
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims := tc.setupClaims()
w.WriteHeader(http.StatusOK) claimsJSON, _ := json.Marshal(claims)
w.Write([]byte("OK")) encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON)
}) mockToken := fmt.Sprintf("header.%s.signature", encodedClaims)
session.Values["id_token"] = mockToken
suite.oidc.next = nextHandler suite.mockStore.On("Get", req, cookieName).Return(session, nil)
suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil)
suite.oidc.ServeHTTP(rw, req) suite.mockTokenVerifier.On("VerifyToken", mockToken).Return(nil)
suite.Equal(http.StatusOK, rw.Code) nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
suite.Equal("OK", rw.Body.String()) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
suite.oidc.next = nextHandler
suite.oidc.ServeHTTP(rw, req)
suite.Equal(tc.expectedStatus, rw.Code)
suite.Contains(rw.Body.String(), tc.expectedBody)
suite.Equal(tc.expectedHeader, req.Header.Get("X-Forwarded-User"))
})
}
} }
func (suite *TraefikOidcTestSuite) TestServeHTTP_CallbackPath() { func (suite *TraefikOidcTestSuite) TestServeHTTP_CallbackPath() {
@@ -533,18 +568,48 @@ func (suite *TraefikOidcTestSuite) TestServeHTTP_ExpiredToken() {
} }
func (suite *TraefikOidcTestSuite) TestHandleCallback_InvalidState() { func (suite *TraefikOidcTestSuite) TestHandleCallback_InvalidState() {
req := httptest.NewRequest("GET", "http://example.com"+suite.oidc.redirURLPath+"?code=test_code&state=invalid_state", nil) testCases := []struct {
rw := httptest.NewRecorder() name string
setupSession func() *sessions.Session
expectedStatus int
expectedBody string
}{
{
name: "Invalid state",
setupSession: func() *sessions.Session {
session := sessions.NewSession(suite.mockStore, cookieName)
session.Values["csrf"] = "valid_state"
return session
},
expectedStatus: http.StatusFound,
expectedBody: "Authentication failed",
},
{
name: "No CSRF in session",
setupSession: func() *sessions.Session {
return sessions.NewSession(suite.mockStore, cookieName)
},
expectedStatus: http.StatusFound,
expectedBody: "Authentication failed",
},
}
session := sessions.NewSession(suite.mockStore, cookieName) for _, tc := range testCases {
session.Values["csrf"] = "valid_state" suite.Run(tc.name, func() {
req := httptest.NewRequest("GET", "http://example.com"+suite.oidc.redirURLPath+"?code=test_code&state=invalid_state", nil)
rw := httptest.NewRecorder()
suite.mockStore.On("Get", req, cookieName).Return(session, nil) session := tc.setupSession()
suite.oidc.ServeHTTP(rw, req) suite.mockStore.On("Get", req, cookieName).Return(session, nil)
suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil)
suite.Equal(http.StatusBadRequest, rw.Code) suite.oidc.ServeHTTP(rw, req)
suite.Contains(rw.Body.String(), "Invalid state parameter")
suite.Equal(tc.expectedStatus, rw.Code)
suite.Contains(rw.Body.String(), tc.expectedBody)
})
}
} }
func (suite *TraefikOidcTestSuite) TestHandleCallback_TokenExchangeError() { func (suite *TraefikOidcTestSuite) TestHandleCallback_TokenExchangeError() {
@@ -815,3 +880,133 @@ func (suite *TraefikOidcTestSuite) TestServeHTTP_ExcludedURLs() {
}) })
} }
} }
func (suite *TraefikOidcTestSuite) TestServeHTTP_DomainRestriction() {
testCases := []struct {
name string
allowedDomains map[string]struct{}
email string
expectedStatus int
expectedBody string
expectedHeader string
}{
{
name: "Allowed domain",
allowedDomains: map[string]struct{}{"example.com": {}},
email: "[email protected]",
expectedStatus: http.StatusOK,
expectedBody: "OK",
expectedHeader: "[email protected]",
},
{
name: "Not allowed domain",
allowedDomains: map[string]struct{}{"example.com": {}},
email: "[email protected]",
expectedStatus: http.StatusForbidden,
expectedBody: "Access denied: Your email domain is not allowed",
expectedHeader: "",
},
{
name: "No domain restriction",
allowedDomains: map[string]struct{}{},
email: "[email protected]",
expectedStatus: http.StatusOK,
expectedBody: "OK",
expectedHeader: "[email protected]",
},
{
name: "No email claim",
allowedDomains: map[string]struct{}{"example.com": {}},
email: "",
expectedStatus: http.StatusFound,
expectedBody: "",
expectedHeader: "",
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.oidc.allowedUserDomains = tc.allowedDomains
req := httptest.NewRequest("GET", "http://example.com", nil)
rw := httptest.NewRecorder()
session := sessions.NewSession(suite.mockStore, cookieName)
session.Values["authenticated"] = true
claims := map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
if tc.email != "" {
claims["email"] = tc.email
}
claimsJSON, _ := json.Marshal(claims)
encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON)
mockToken := fmt.Sprintf("header.%s.signature", encodedClaims)
session.Values["id_token"] = mockToken
suite.mockStore.On("Get", req, cookieName).Return(session, nil).Once()
suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
suite.mockTokenVerifier.On("VerifyToken", mockToken).Return(nil).Once()
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
suite.oidc.next = nextHandler
suite.oidc.ServeHTTP(rw, req)
suite.Equal(tc.expectedStatus, rw.Code)
suite.Contains(rw.Body.String(), tc.expectedBody)
suite.Equal(tc.expectedHeader, req.Header.Get("X-Forwarded-User"))
suite.mockStore.AssertExpectations(suite.T())
suite.mockTokenVerifier.AssertExpectations(suite.T())
})
}
}
func (suite *TraefikOidcTestSuite) TestIsAllowedDomain() {
testCases := []struct {
name string
email string
allowedDomains map[string]struct{}
expectedAllowed bool
}{
{
name: "Allowed domain",
email: "[email protected]",
allowedDomains: map[string]struct{}{"example.com": {}, "test.com": {}},
expectedAllowed: true,
},
{
name: "Not allowed domain",
email: "[email protected]",
allowedDomains: map[string]struct{}{"example.com": {}, "test.com": {}},
expectedAllowed: false,
},
{
name: "Empty allowed domains",
email: "[email protected]",
allowedDomains: map[string]struct{}{},
expectedAllowed: true,
},
{
name: "Invalid email format",
email: "invalidemail",
allowedDomains: map[string]struct{}{"example.com": {}},
expectedAllowed: false,
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.oidc.allowedUserDomains = tc.allowedDomains
allowed := suite.oidc.isAllowedDomain(tc.email)
suite.Equal(tc.expectedAllowed, allowed)
})
}
}
+1
View File
@@ -24,6 +24,7 @@ type Config struct {
ForceHTTPS bool `json:"forceHTTPS"` ForceHTTPS bool `json:"forceHTTPS"`
RateLimit int `json:"rateLimit"` RateLimit int `json:"rateLimit"`
ExcludedURLs []string `json:"excludedURLs"` ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
} }
func CreateConfig() *Config { func CreateConfig() *Config {