Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
211 changes: 211 additions & 0 deletions gateway/auth/ldap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
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 {
Comment thread
jk4102 marked this conversation as resolved.
Outdated
config internal.EnvVars
}

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

// Authenticate performs only LDAP authentication without authorization
Comment thread
jk4102 marked this conversation as resolved.
Outdated
func (la *LDAPAuthenticator) Authenticate(req *http.Request) (string, error) {
if !la.config.LdapEnabled {
return "", nil // LDAP is disabled, skip authentication
}

// Extract credentials from Authorization header
username, password, err := la.extractCredentials(req)
if err != nil {
return "", err
}

// Authenticate against LDAP
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)
}

// extractCredentials extracts username and password from Basic Auth header
func (la *LDAPAuthenticator) extractCredentials(req *http.Request) (string, string, error) {
Comment thread
jk4102 marked this conversation as resolved.
Outdated
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return "", "", fmt.Errorf("missing Authorization header")
}

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

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

// Split username and password
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) {
// Connect to LDAP server
conn, err := la.connectLDAP()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we keep connection open globally (check concurrency support)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as jupyterlab version - concurrency not built in

if err != nil {
return "", fmt.Errorf("failed to connect to LDAP server: %w", err)
}
defer conn.Close()

// Bind with service account if configured
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)
Comment thread
jk4102 marked this conversation as resolved.
Outdated
return "", fmt.Errorf("LDAP service account bind failed")
}
}

// Search for user
userDN, err := la.searchUser(conn, username)
if err != nil {
return "", 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)
Comment thread
jk4102 marked this conversation as resolved.
Outdated
return "", 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) {
address := fmt.Sprintf("%s:%d", la.config.LdapServer, la.config.LdapPort)
fmt.Println("Connecting to LDAP server at", address)

var conn *ldap.Conn
var err error

if la.config.LdapTLS {
tlsConfig := &tls.Config{
ServerName: la.config.LdapServer,
}
conn, err = ldap.DialTLS("tcp", address, tlsConfig)
} else {
conn, err = ldap.Dial("tcp", address)
}

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) {
// Build search filter
filter := fmt.Sprintf("(&(%s=%s)%s)",
la.config.LdapUserAttribute,
ldap.EscapeFilter(username),
la.config.LdapUserFilter)

// Perform search
searchRequest := ldap.NewSearchRequest(
la.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, // Size limit
0, // Time limit
false, // Types only
filter,
[]string{"dn"}, // Attributes to return
Comment thread
jk4102 marked this conversation as resolved.
Outdated
nil, // Controls
)

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 // Authorization is disabled, allow all authenticated users
}

if len(la.config.AllowedUsers) == 0 {
return nil // No restrictions if allowed users list is empty
}

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)
Comment thread
jk4102 marked this conversation as resolved.
Outdated
return fmt.Errorf("user not authorized")
}
18 changes: 15 additions & 3 deletions gateway/internal/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,21 @@ 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:"true"`
LdapServer string `env:"LDAP_SERVER" envDefault:"openldap.tinyapp.svc.cluster.local"` // 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
Comment thread
jk4102 marked this conversation as resolved.
Outdated
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"` // Additional filter for user search
AuthorizationEnabled bool `env:"AUTHORIZATION_ENABLED" envDefault:"true"`
AllowedUsers []string `env:"ALLOWED_USERS" default:""` // Comma-separated list of allowed usernames
}
65 changes: 61 additions & 4 deletions gateway/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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"
Expand All @@ -34,6 +35,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 +55,84 @@ func NewProxyServerConfig(envVars internal.EnvVars) (*proxyServerConfig, error)
secondaryProxy = httputil.NewSingleHostReverseProxy(secondaryTargetUrl)
}

// Initialize LDAP authenticator
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())

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

// Step 2: Authorize the user (if authentication succeeded)
if 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
}
}

// Set the authenticated username for metrics and logging
authenticatedUser := globalutil.AnyUserName
if username != "" {
authenticatedUser = username
zap.S().Debugw("authenticated and authorized user", "username", username)
}

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", authenticatedUser)
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", authenticatedUser)
metrics.UsernameCounter.WithLabelValues(authenticatedUser).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
Loading