Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement basic auth provider structure #1864

Merged
merged 15 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
14 changes: 13 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
links:
- etcd
- redis
- sandboxesMock
Matovidlo marked this conversation as resolved.
Show resolved Hide resolved
volumes:
- ./:/code:z
- cache:/tmp/cache
Expand Down Expand Up @@ -71,7 +72,7 @@ services:
ports:
- "6379:6379"
volumes:
- ./redis.conf:/etc/redis/redis.conf
- ./provisioning/common/redis/redis.conf:/etc/redis/redis.conf
environment:
REDIS_PORT: 6379
# Same etcd is used for all services, but with different namespace
Expand Down Expand Up @@ -102,5 +103,16 @@ services:
- K6_USERS
- K6_DURATION

sandboxesMock:
image: mockserver/mockserver:latest
ports:
- 1080:1080
environment:
MOCKSERVER_WATCH_INITIALIZATION_JSON: "true"
MOCKSERVER_PROPERTY_FILE: /config/mockserver.properties
MOCKSERVER_INITIALIZATION_JSON_PATH: /config/sandboxesMock.json
volumes:
- ./provisioning/apps-proxy/dev/sandboxesMock.json:/config/sandboxesMock.json:Z

volumes:
cache:
1 change: 1 addition & 0 deletions internal/pkg/service/appsproxy/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Config struct {
CookieSecretSalt string `configKey:"cookieSecretSalt" configUsage:"Cookie secret needed by OAuth 2 Proxy." validate:"required" sensitive:"true"`
Upstream Upstream `configKey:"-" configUsage:"Configuration options for upstream"`
SandboxesAPI SandboxesAPI `configKey:"sandboxesAPI"`
CsrfTokenSalt string `configKey:"csrfTokenSalt" configUsage:"Salt used for generating CSRF tokens" validate:"required" sensitive:"true"`
}

type API struct {
Expand Down
10 changes: 10 additions & 0 deletions internal/pkg/service/appsproxy/dataapps/auth/provider/basic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package provider

type Basic struct {
Base
Password string `json:"password" sensitive:"true"`
}

func (p *Basic) IsAuthorized(password string) bool {
return p.Password == password
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
)

const (
TypeOIDC = Type("oidc")
TypeOIDC Type = "oidc"
TypeBasic Type = "password"
)

// ID is unique identifier of the authentication provider inside a data app.
Expand Down Expand Up @@ -37,6 +38,8 @@ func (t Type) new() (Provider, error) {
switch t {
case TypeOIDC:
return OIDC{}, nil
case TypeBasic:
return Basic{}, nil
default:
return nil, errors.Errorf(`unexpected type of data app auth provider "%v"`, t)
}
Expand Down
12 changes: 6 additions & 6 deletions internal/pkg/service/appsproxy/dependencies/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/notify"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/wakeup"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/oidcproxy"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/upstream"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/pagewriter"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/transport"
Expand All @@ -53,7 +53,7 @@ type ServiceScope interface {
AppConfigLoader() *appconfig.Loader
UpstreamTransport() http.RoundTripper
UpstreamManager() *upstream.Manager
OidcProxyManager() *oidcproxy.Manager
AuthProxyManager() *authproxy.Manager
Matovidlo marked this conversation as resolved.
Show resolved Hide resolved
PageWriter() *pagewriter.Writer
NotifyManager() *notify.Manager
WakeupManager() *wakeup.Manager
Expand All @@ -73,7 +73,7 @@ type serviceScope struct {
appHandlers *apphandler.Manager
upstreamTransport http.RoundTripper
upstreamManager *upstream.Manager
oidcProxyManager *oidcproxy.Manager
authProxyManager *authproxy.Manager
pageWriter *pagewriter.Writer
appConfigLoader *appconfig.Loader
notifyManager *notify.Manager
Expand Down Expand Up @@ -154,7 +154,7 @@ func newServiceScope(ctx context.Context, parentScp parentScopes, cfg config.Con
d.appConfigLoader = appconfig.NewLoader(d)
d.notifyManager = notify.NewManager(d)
d.wakeupManager = wakeup.NewManager(d)
d.oidcProxyManager = oidcproxy.NewManager(d)
d.authProxyManager = authproxy.NewManager(d)
d.upstreamManager = upstream.NewManager(d)
d.appHandlers = apphandler.NewManager(d)

Expand Down Expand Up @@ -185,8 +185,8 @@ func (v *serviceScope) UpstreamManager() *upstream.Manager {
return v.upstreamManager
}

func (v *serviceScope) OidcProxyManager() *oidcproxy.Manager {
return v.oidcProxyManager
func (v *serviceScope) AuthProxyManager() *authproxy.Manager {
return v.authProxyManager
}

func (v *serviceScope) PageWriter() *pagewriter.Writer {
Expand Down
3 changes: 3 additions & 0 deletions internal/pkg/service/appsproxy/dependencies/mocked.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ func NewMockedServiceScope(t *testing.T, cfg config.Config, opts ...dependencies
if cfg.CookieSecretSalt == "" {
cfg.CookieSecretSalt = "foo"
}
if cfg.CsrfTokenSalt == "" {
cfg.CsrfTokenSalt = "bar"
}
if cfg.SandboxesAPI.URL == "" {
cfg.SandboxesAPI.URL = "http://sandboxes-service-api.default.svc.cluster.local"
}
Expand Down
10 changes: 5 additions & 5 deletions internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/api"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/auth/provider"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/selector"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/chain"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/oidcproxy"
"github.com/keboola/keboola-as-code/internal/pkg/service/common/ctxattr"
svcErrors "github.com/keboola/keboola-as-code/internal/pkg/service/common/errors"
"github.com/keboola/keboola-as-code/internal/pkg/service/common/httpserver/middleware"
Expand All @@ -33,7 +33,7 @@ type appHandler struct {

type ruleIndex int

func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handler, authHandlers map[provider.ID]*oidcproxy.Handler) (http.Handler, error) {
func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handler, authHandlers map[provider.ID]selector.Handler) (http.Handler, error) {
handler := &appHandler{
manager: manager,
app: app,
Expand All @@ -45,7 +45,7 @@ func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handle

// Create handler with all auth handlers, to route internal URLs
if len(authHandlers) > 0 {
if h, err := manager.oidcProxyManager.ProviderSelector().For(app, authHandlers); err == nil {
if h, err := manager.authProxyManager.ProviderSelector().For(app, authHandlers); err == nil {
handler.allAuthHandlers = h
} else {
return nil, err
Expand Down Expand Up @@ -78,7 +78,7 @@ func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handle
}

// Filter authentication handlers
authHandlersPerRule := make(map[provider.ID]*oidcproxy.Handler)
authHandlersPerRule := make(map[provider.ID]selector.Handler)
for _, providerID := range rule.Auth {
if authHandler, found := authHandlers[providerID]; found {
authHandlersPerRule[providerID] = authHandler
Expand All @@ -88,7 +88,7 @@ func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handle
}

// Merge authentication handlers for the rule to one selector handler
if h, err := manager.oidcProxyManager.ProviderSelector().For(app, authHandlersPerRule); err == nil {
if h, err := manager.authProxyManager.ProviderSelector().For(app, authHandlersPerRule); err == nil {
handler.authHandlerPerRule[index] = h
} else {
return nil, err
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package basicauth

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"net/http"
"net/url"
"time"

"github.com/benbjohnson/clock"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
"golang.org/x/net/xsrftoken"

"github.com/keboola/keboola-as-code/internal/pkg/log"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/api"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/auth/provider"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/selector"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/chain"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/pagewriter"
"github.com/keboola/keboola-as-code/internal/pkg/utils/errors"
)

const (
basicAuthCookie = "proxyBasicAuth"
callbackQueryParam = "rd" // value match OAuth2Proxy internals and shouldn't be modified (see AppDirector there)
formPagePath = config.InternalPrefix + "/form"
csrfTokenKey = "_csrf"
csrfTokenMaxAge = 15 * time.Minute
)

type Handler struct {
logger log.Logger
clock clock.Clock
pageWriter *pagewriter.Writer
csrfTokenSalt string
publicURL *url.URL
app api.AppConfig
basicAuth provider.Basic
upstream chain.Handler
}

func NewHandler(
logger log.Logger,
config config.Config,
clock clock.Clock,
pageWriter *pagewriter.Writer,
app api.AppConfig,
auth provider.Basic,
upstream chain.Handler,
) *Handler {
return &Handler{
logger: logger,
clock: clock,
pageWriter: pageWriter,
csrfTokenSalt: config.CsrfTokenSalt,
publicURL: config.API.PublicURL,
app: app,
basicAuth: auth,
upstream: upstream,
}
}

func (h *Handler) Name() string {
return h.basicAuth.Name()
}

func (h *Handler) SignInPath() string {
return formPagePath
}

func (h *Handler) CookieExpiration() time.Duration {
return 1 * time.Hour
Matovidlo marked this conversation as resolved.
Show resolved Hide resolved
}

func (h *Handler) ServeHTTPOrError(w http.ResponseWriter, req *http.Request) error {
host, _ := util.SplitHostPort(req.Host)
if host == "" {
return errors.New("internal server error")
}

requestCookie, _ := req.Cookie(basicAuthCookie)
// Pass request to upstream when cookie have been set
if requestCookie != nil && req.URL.Path != selector.SignOutPath && h.isCookieAuthorized(requestCookie) == nil {
h.setCookie(w, host, h.CookieExpiration(), requestCookie)
return h.upstream.ServeHTTPOrError(w, req)
}

// Unset cookie as /_proxy/sign_out was called and enforce login by redirecting to SignInPath
if requestCookie != nil && req.URL.Path == selector.SignOutPath {
h.signOut(host, requestCookie, w, req)
return nil
}

if err := req.ParseForm(); err != nil {
return err
}

// CSRF token validation
if key := req.Form.Get(csrfTokenKey); key != "" && !xsrftoken.ValidFor(key, csrfTokenKey, h.csrfTokenSalt, "/", csrfTokenMaxAge) {
h.pageWriter.WriteErrorPage(w, req, &h.app, http.StatusForbidden, "Session expired, reload page", pagewriter.ExceptionIDPrefix+key)
return nil
}

// Login page first access
csrfToken := xsrftoken.Generate(csrfTokenKey, h.csrfTokenSalt, "/")
fragment := fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, template.HTMLEscapeString(csrfTokenKey), template.HTMLEscapeString(csrfToken))
if !req.Form.Has("password") && requestCookie == nil {
h.pageWriter.WriteLoginPage(w, req, &h.app, template.HTML(fragment), nil) // #nosec G203
Matovidlo marked this conversation as resolved.
Show resolved Hide resolved
return nil
}

// Login page with unauthorized alert
p := req.Form.Get("password")
if err := h.isAuthorized(p, requestCookie); err != nil {
h.logger.Warn(req.Context(), err.Error())
h.pageWriter.WriteLoginPage(w, req, &h.app, template.HTML(fragment), err) // #nosec G203
return nil
}

// Redirect to page that user comes from
path := req.Header.Get(callbackQueryParam)
if path == "" {
path = "/"
}

redirectURL := &url.URL{
Scheme: h.publicURL.Scheme,
Host: req.Host,
Path: path,
}
hash := sha256.New()
hash.Write([]byte(p + string(h.app.ID)))
hashedValue := hash.Sum(nil)
v := &http.Cookie{
Value: hex.EncodeToString(hashedValue),
}
h.setCookie(w, host, h.CookieExpiration(), v)
// Redirect to upstream (Same handler)
w.Header().Set("Location", redirectURL.String())
w.WriteHeader(http.StatusMovedPermanently)
return nil
}

func (h *Handler) isAuthorized(password string, cookie *http.Cookie) error {
if password != "" && !h.basicAuth.IsAuthorized(password) {
return errors.New("Please enter a correct password.")
}

return h.isCookieAuthorized(cookie)
}

func (h *Handler) isCookieAuthorized(cookie *http.Cookie) error {
if err := cookie.Valid(); cookie != nil && err != nil {
return err
}

if cookie != nil {
hash := sha256.New()
hash.Write([]byte(h.basicAuth.Password + string(h.app.ID)))
Matovidlo marked this conversation as resolved.
Show resolved Hide resolved
hashedValue := hash.Sum(nil)
if hex.EncodeToString(hashedValue) != cookie.Value {
return errors.New("Cookie has expired.")
}
}

return nil
}

func (h *Handler) signOut(host string, cookie *http.Cookie, w http.ResponseWriter, req *http.Request) {
cookie.Value = ""
h.setCookie(w, host, -1, cookie)
redirectURL := &url.URL{
Scheme: h.publicURL.Scheme,
Host: req.Host,
Path: h.SignInPath(),
}
w.Header().Set("Location", redirectURL.String())
w.WriteHeader(http.StatusFound)
}

func (h *Handler) setCookie(w http.ResponseWriter, host string, expires time.Duration, cookie *http.Cookie) {
v := &http.Cookie{
Name: basicAuthCookie,
Value: cookie.Value,
Path: "/",
Domain: host,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}

if expires > 0 {
// If there is an expiration, set it
v.Expires = h.clock.Now().Add(expires)
} else {
// Otherwise clear the cookie
v.MaxAge = -1
}

http.SetCookie(w, v)
}
Loading
Loading