Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions controller/reconciler/builder/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ func buildGatewayEnvVars(app *v1alpha1.TinyApp, env internal.EnvVars) []corev1.E
{Name: "METRICS_TLS_ENABLED", Value: strconv.FormatBool(env.GatewayMetricsTlsEnabled)},
{Name: "METRICS_PORT", Value: env.GatewayMetricsPort},
{Name: "METRICS_PATH", Value: env.GatewayMetricsPath},
{Name: "ALLOWED_USERS", Value: strings.Join(app.Spec.AllowedUsers, ",")},
}

envVars = append(envVars, buildEnvVarsList(env.GatewayEnvVars)...)
Expand Down
208 changes: 208 additions & 0 deletions gateway/auth/ldap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
Copyright 2024 BlackRock, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package auth

import (
"crypto/tls"
"encoding/base64"
"fmt"
"net/http"
"strings"

"github.com/go-ldap/ldap/v3"
"github.com/tinymultiverse/tinyapp/gateway/internal"
"go.uber.org/zap"
)

type LDAPAuthenticator struct {
config internal.EnvVars
}

func NewLDAPAuthenticator(config internal.EnvVars) *LDAPAuthenticator {
return &LDAPAuthenticator{
config: config,
}
}

// Authenticate performs only LDAP authentication without authorization
func (la *LDAPAuthenticator) Authenticate(req *http.Request) (string, error) {
if !la.config.LdapEnabled {
return "", nil
}

username, password, err := la.extractCredentials(req)
if err != nil {
return "", err
}

return la.authenticateLDAP(username, password)
}

// AuthorizeUser checks if the authenticated user is in the allowed users list
func (la *LDAPAuthenticator) AuthorizeUser(username string) error {
return la.authorizeUser(username)
}

func (la *LDAPAuthenticator) extractCredentials(req *http.Request) (string, string, error) {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return "", "", fmt.Errorf("missing Authorization header")
}

if !strings.HasPrefix(authHeader, "Basic ") {
return "", "", fmt.Errorf("unsupported authorization type")
}

encoded := strings.TrimPrefix(authHeader, "Basic ")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", fmt.Errorf("invalid base64 encoding: %w", err)
}

credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
return "", "", fmt.Errorf("invalid credentials format")
}

return credentials[0], credentials[1], nil
}

// authenticateLDAP performs LDAP authentication
func (la *LDAPAuthenticator) authenticateLDAP(username, password string) (string, error) {
conn, err := la.connectLDAP()
if err != nil {
return "", fmt.Errorf("failed to connect to LDAP server: %w", err)
}
defer conn.Close()

if la.config.LdapBindDN != "" {
err = conn.Bind(la.config.LdapBindDN, la.config.LdapBindPassword)
if err != nil {
zap.S().Errorw("failed to bind with service account", "error", err)
return "", fmt.Errorf("LDAP service account bind failed")
}
}

userDN, err := la.searchUser(conn, username)
if err != nil {
return username, fmt.Errorf("user search failed: %w", err)
}

// Authenticate user by binding with their credentials
err = conn.Bind(userDN, password)
if err != nil {
zap.S().Debugw("user authentication failed", "username", username, "error", err)
return username, fmt.Errorf("authentication failed")
}

zap.S().Infow("user authenticated successfully", "username", username)
return username, nil
}

// connectLDAP establishes connection to LDAP server
func (la *LDAPAuthenticator) connectLDAP() (*ldap.Conn, error) {
var scheme string
if la.config.LdapTLS {
scheme = "ldaps"
} else {
scheme = "ldap"
}

ldapURL := fmt.Sprintf("%s://%s:%d", scheme, la.config.LdapServer, la.config.LdapPort)
fmt.Println("Connecting to LDAP server at", ldapURL)

var conn *ldap.Conn
var err error

if la.config.LdapTLS {
tlsConfig := &tls.Config{
ServerName: la.config.LdapServer,
}
conn, err = ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(tlsConfig))
} else {
conn, err = ldap.DialURL(ldapURL)
}

if err != nil {
return nil, err
}

return conn, nil
}

// searchUser searches for user in LDAP directory
func (la *LDAPAuthenticator) searchUser(conn *ldap.Conn, username string) (string, error) {
filter := fmt.Sprintf("(&(%s=%s)%s)",
la.config.LdapUserAttribute,
ldap.EscapeFilter(username),
la.config.LdapUserFilter)

searchRequest := ldap.NewSearchRequest(
la.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
la.config.LdapSearchSizeLimit,
la.config.LdapSearchTimeLimit,
false,
filter,
la.config.LdapReturnAttributes,
nil,
)

result, err := conn.Search(searchRequest)
if err != nil {
return "", err
}

if len(result.Entries) == 0 {
return "", fmt.Errorf("user not found")
}

if len(result.Entries) > 1 {
return "", fmt.Errorf("multiple users found")
}

return result.Entries[0].DN, nil
}

// RequireAuth is a middleware that sends 401 with WWW-Authenticate header
func (la *LDAPAuthenticator) RequireAuth(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="LDAP Authentication"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
}

// authorizeUser checks if the authenticated user is in the allowed users list
func (la *LDAPAuthenticator) authorizeUser(username string) error {
if !la.config.AuthorizationEnabled {
return nil
}

if len(la.config.AllowedUsers) == 0 {
return nil
}

for _, allowedUser := range la.config.AllowedUsers {
if strings.TrimSpace(allowedUser) == username {
zap.S().Debugw("user authorized", "username", username)
return nil
}
}

zap.S().Warnw("user not in allowed users list", "username", username, "allowedUsers", la.config.AllowedUsers)
return fmt.Errorf("user not authorized")
}
21 changes: 18 additions & 3 deletions gateway/internal/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,24 @@ type EnvVars struct {
PrimaryTargetPort string `env:"PRIMARY_TARGET_PORT" envDefault:"5000"`
SecondaryTargetPattern string `env:"SECONDARY_TARGET_PATTERN" envDefault:""`
SecondaryTargetPort string `env:"SECONDARY_TARGET_PORT" envDefault:""`
MetricsEnabled bool `env:"METRICS_ENABLED" envDefault:"true"`
MetricsEnabled bool `env:"METRICS_ENABLED" envDefault:"false"`
MetricsTlsEnabled bool `env:"METRICS_TLS_ENABLED" envDefault:"false"`
MetricsPort string `env:"METRICS_PORT"` // Required if METRICS_ENABLED is true
MetricsPath string `env:"METRICS_PATH"` // Required if METRICS_ENABLED is true
MetricsPort string `env:"METRICS_PORT" default:"9090"` // Required if METRICS_ENABLED is true
MetricsPath string `env:"METRICS_PATH"` // Required if METRICS_ENABLED is true
URLSubPath string `env:"URL_SUB_PATH" envDefault:"/"`
// LDAP Configuration
LdapEnabled bool `env:"LDAP_ENABLED" envDefault:"false"`
LdapServer string `env:"LDAP_SERVER"` // Required if LDAP_ENABLED is true
LdapPort int `env:"LDAP_PORT" envDefault:"389"` // 389 for LDAP, 636 for LDAPS
LdapTLS bool `env:"LDAP_TLS" envDefault:"false"` // Use TLS connection
LdapBaseDN string `env:"LDAP_BASE_DN" envDefault:"ou=people,dc=example,dc=org"` // Required if LDAP_ENABLED is true
LdapBindDN string `env:"LDAP_BIND_DN" envDefault:"cn=admin,dc=example,dc=org"` // Service account DN for searching
LdapBindPassword string `env:"LDAP_BIND_PASSWORD" envDefault:"adminpassword"` // Service account password
LdapUserAttribute string `env:"LDAP_USER_ATTRIBUTE" envDefault:"uid"` // Attribute to search for username
LdapUserFilter string `env:"LDAP_USER_FILTER" envDefault:"(objectClass=person)"` // Additional filter for user search
LdapSearchSizeLimit int `env:"LDAP_SEARCH_SIZE_LIMIT" envDefault:"1"` // Max search results
LdapSearchTimeLimit int `env:"LDAP_SEARCH_TIME_LIMIT" envDefault:"30"` // Search timeout in seconds
LdapReturnAttributes []string `env:"LDAP_RETURN_ATTRIBUTES" envDefault:"dn"` // Attributes to return from search
AuthorizationEnabled bool `env:"AUTHORIZATION_ENABLED" envDefault:"false"`
AllowedUsers []string `env:"ALLOWED_USERS" default:""` // Comma-separated list of allowed usernames
}
56 changes: 51 additions & 5 deletions gateway/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import (
"path"
"strings"

"github.com/tinymultiverse/tinyapp/gateway/auth"
"github.com/tinymultiverse/tinyapp/gateway/internal"
"github.com/tinymultiverse/tinyapp/gateway/util/metrics"
globalutil "github.com/tinymultiverse/tinyapp/util"
"go.uber.org/zap"
)

Expand All @@ -34,6 +34,7 @@ type proxyServerConfig struct {
SecondaryProxy *httputil.ReverseProxy
SecondaryTargetPattern string
URLSubPath string
authenticator *auth.LDAPAuthenticator
}

func NewProxyServerConfig(envVars internal.EnvVars) (*proxyServerConfig, error) {
Expand All @@ -53,29 +54,74 @@ func NewProxyServerConfig(envVars internal.EnvVars) (*proxyServerConfig, error)
secondaryProxy = httputil.NewSingleHostReverseProxy(secondaryTargetUrl)
}

authenticator := auth.NewLDAPAuthenticator(envVars)

return &proxyServerConfig{
Proxy: proxy,
SecondaryProxy: secondaryProxy,
SecondaryTargetPattern: envVars.SecondaryTargetPattern,
URLSubPath: envVars.URLSubPath,
authenticator: authenticator,
}, nil
}

func (p *proxyServerConfig) ServeHTTP(res http.ResponseWriter, req *http.Request) {
zap.S().Debugw("got a request", "host", req.Host, "method", req.Method, "requestURL", req.URL.String())

username, err := p.authenticator.Authenticate(req)
if err != nil {
zap.S().Warnw("authentication failed", "username", username, "error", err, "remoteAddr", req.RemoteAddr)
p.authenticator.RequireAuth(res)
return
}

zap.S().Infow("authenticated user", "username", username)

err = p.authenticator.AuthorizeUser(username)
if err != nil {
zap.S().Warnw("authorization failed", "username", username, "error", err, "remoteAddr", req.RemoteAddr)
p.sendUnauthorizedResponse(res, username)
return
}

if p.SecondaryProxy != nil && strings.Contains(req.URL.Path, p.SecondaryTargetPattern) {
zap.S().Debugw("routing to secondary proxy", "path", req.URL.Path)
zap.S().Debugw("routing to secondary proxy", "path", req.URL.Path, "user", username)
p.SecondaryProxy.ServeHTTP(res, req)
return
}

// Only increment user count if the request URL is app homepage
if path.Clean(req.URL.Path) == path.Clean(p.URLSubPath) {
zap.S().Info("Incrementing user count")
// TODO Once integrated with OAuth, get actual username from auth server
metrics.UsernameCounter.WithLabelValues(globalutil.AnyUserName).Inc()
zap.S().Infow("Incrementing user count", "user", username)
metrics.UsernameCounter.WithLabelValues(username).Inc()
}

p.Proxy.ServeHTTP(res, req)
}

// sendUnauthorizedResponse sends an HTML response for unauthorized users
func (p *proxyServerConfig) sendUnauthorizedResponse(res http.ResponseWriter, username string) {
res.Header().Set("Content-Type", "text/html")
res.WriteHeader(http.StatusForbidden)
html := `<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body { font-family: Arial, sans-serif; margin: 50px; text-align: center; }
.container { max-width: 500px; margin: 0 auto; }
h1 { color: #d32f2f; }
p { color: #666; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<h1>Access Denied</h1>
<p>You do not have access to this app.</p>
<p>User: ` + username + `</p>
<p>Please contact your administrator if you believe this is an error.</p>
</div>
</body>
</html>`
res.Write([]byte(html))
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ require (
)

require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
Expand All @@ -33,6 +34,8 @@ require (
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.12 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
Expand Down Expand Up @@ -68,6 +71,7 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
Expand Down Expand Up @@ -46,6 +48,10 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew=
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
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.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
Expand Down Expand Up @@ -228,6 +234,8 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
Expand Down
Loading