mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-03-14 02:32:02 +00:00
475 lines
13 KiB
Go
475 lines
13 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/steveiliop56/tinyauth/internal/config"
|
|
"github.com/steveiliop56/tinyauth/internal/service"
|
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/go-querystring/query"
|
|
)
|
|
|
|
type RequestType int
|
|
|
|
const (
|
|
AuthRequest RequestType = iota
|
|
ExtAuthz
|
|
ForwardAuth
|
|
)
|
|
|
|
var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge")
|
|
|
|
var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"}
|
|
|
|
type Proxy struct {
|
|
Proxy string `uri:"proxy" binding:"required"`
|
|
}
|
|
|
|
type ProxyContext struct {
|
|
Host string
|
|
Proto string
|
|
Path string
|
|
Method string
|
|
Type RequestType
|
|
IsBrowser bool
|
|
}
|
|
|
|
type ProxyControllerConfig struct {
|
|
AppURL string
|
|
}
|
|
|
|
type ProxyController struct {
|
|
config ProxyControllerConfig
|
|
router *gin.RouterGroup
|
|
acls *service.AccessControlsService
|
|
auth *service.AuthService
|
|
}
|
|
|
|
func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController {
|
|
return &ProxyController{
|
|
config: config,
|
|
router: router,
|
|
acls: acls,
|
|
auth: auth,
|
|
}
|
|
}
|
|
|
|
func (controller *ProxyController) SetupRoutes() {
|
|
proxyGroup := controller.router.Group("/auth")
|
|
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
|
}
|
|
|
|
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|
// Load proxy context based on the request type
|
|
proxyCtx, err := controller.getProxyContext(c)
|
|
|
|
if err != nil {
|
|
tlog.App.Warn().Err(err).Msg("Failed to get proxy context")
|
|
c.JSON(400, gin.H{
|
|
"status": 400,
|
|
"message": "Bad request",
|
|
})
|
|
return
|
|
}
|
|
|
|
tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
|
|
|
|
// Get acls
|
|
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
|
|
|
|
if err != nil {
|
|
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
|
|
controller.handleError(c, proxyCtx)
|
|
return
|
|
}
|
|
|
|
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource")
|
|
|
|
clientIP := c.ClientIP()
|
|
|
|
if controller.auth.IsBypassedIP(acls.IP, clientIP) {
|
|
controller.setHeaders(c, acls)
|
|
c.JSON(200, gin.H{
|
|
"status": 200,
|
|
"message": "Authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)
|
|
|
|
if err != nil {
|
|
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
|
|
controller.handleError(c, proxyCtx)
|
|
return
|
|
}
|
|
|
|
if !authEnabled {
|
|
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access")
|
|
controller.setHeaders(c, acls)
|
|
c.JSON(200, gin.H{
|
|
"status": 200,
|
|
"message": "Authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !controller.auth.CheckIP(acls.IP, clientIP) {
|
|
if !controller.useFriendlyError(proxyCtx) {
|
|
c.JSON(401, gin.H{
|
|
"status": 401,
|
|
"message": "Unauthorized",
|
|
})
|
|
return
|
|
}
|
|
|
|
queries, err := query.Values(config.UnauthorizedQuery{
|
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
|
IP: clientIP,
|
|
})
|
|
|
|
if err != nil {
|
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode()))
|
|
return
|
|
}
|
|
|
|
var userContext config.UserContext
|
|
|
|
context, err := utils.GetContext(c)
|
|
|
|
if err != nil {
|
|
tlog.App.Debug().Msg("No user context found in request, treating as not logged in")
|
|
userContext = config.UserContext{
|
|
IsLoggedIn: false,
|
|
}
|
|
} else {
|
|
userContext = context
|
|
}
|
|
|
|
tlog.App.Trace().Interface("context", userContext).Msg("User context from request")
|
|
|
|
if userContext.IsLoggedIn {
|
|
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
|
|
|
|
if !userAllowed {
|
|
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
|
|
|
|
if !controller.useFriendlyError(proxyCtx) {
|
|
c.JSON(403, gin.H{
|
|
"status": 403,
|
|
"message": "Forbidden",
|
|
})
|
|
return
|
|
}
|
|
|
|
queries, err := query.Values(config.UnauthorizedQuery{
|
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
|
})
|
|
|
|
if err != nil {
|
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
return
|
|
}
|
|
|
|
if userContext.OAuth {
|
|
queries.Set("username", userContext.Email)
|
|
} else {
|
|
queries.Set("username", userContext.Username)
|
|
}
|
|
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode()))
|
|
return
|
|
}
|
|
|
|
if userContext.OAuth || userContext.Provider == "ldap" {
|
|
var groupOK bool
|
|
|
|
if userContext.OAuth {
|
|
groupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)
|
|
} else {
|
|
groupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups)
|
|
}
|
|
|
|
if !groupOK {
|
|
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
|
|
|
|
if !controller.useFriendlyError(proxyCtx) {
|
|
c.JSON(403, gin.H{
|
|
"status": 403,
|
|
"message": "Forbidden",
|
|
})
|
|
return
|
|
}
|
|
|
|
queries, err := query.Values(config.UnauthorizedQuery{
|
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
|
GroupErr: true,
|
|
})
|
|
|
|
if err != nil {
|
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
return
|
|
}
|
|
|
|
if userContext.OAuth {
|
|
queries.Set("username", userContext.Email)
|
|
} else {
|
|
queries.Set("username", userContext.Username)
|
|
}
|
|
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode()))
|
|
return
|
|
}
|
|
}
|
|
|
|
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
|
|
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
|
|
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
|
|
|
|
if userContext.Provider == "ldap" {
|
|
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.LdapGroups))
|
|
} else if userContext.Provider != "local" {
|
|
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
|
|
}
|
|
|
|
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
|
|
|
|
controller.setHeaders(c, acls)
|
|
|
|
c.JSON(200, gin.H{
|
|
"status": 200,
|
|
"message": "Authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !controller.useFriendlyError(proxyCtx) {
|
|
c.JSON(401, gin.H{
|
|
"status": 401,
|
|
"message": "Unauthorized",
|
|
})
|
|
return
|
|
}
|
|
|
|
queries, err := query.Values(config.RedirectQuery{
|
|
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
|
})
|
|
|
|
if err != nil {
|
|
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode()))
|
|
}
|
|
|
|
func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {
|
|
c.Header("Authorization", c.Request.Header.Get("Authorization"))
|
|
|
|
headers := utils.ParseHeaders(acls.Response.Headers)
|
|
|
|
for key, value := range headers {
|
|
tlog.App.Debug().Str("header", key).Msg("Setting header")
|
|
c.Header(key, value)
|
|
}
|
|
|
|
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
|
|
|
|
if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
|
|
tlog.App.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header")
|
|
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
|
|
}
|
|
}
|
|
|
|
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
|
|
if !controller.useFriendlyError(proxyCtx) {
|
|
c.JSON(500, gin.H{
|
|
"status": 500,
|
|
"message": "Internal Server Error",
|
|
})
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
}
|
|
|
|
func (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) {
|
|
val := c.Request.Header.Get(header)
|
|
return val, strings.TrimSpace(val) != ""
|
|
}
|
|
|
|
func (controller *ProxyController) useFriendlyError(proxyCtx ProxyContext) bool {
|
|
return (proxyCtx.Type == ForwardAuth || proxyCtx.Type == ExtAuthz) && proxyCtx.IsBrowser
|
|
}
|
|
|
|
// Code below is inspired from https://github.com/authelia/authelia/blob/master/internal/handlers/handler_authz.go
|
|
// and thus it may be subject to Apache 2.0 License
|
|
func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyContext, error) {
|
|
host, ok := controller.getHeader(c, "x-forwarded-host")
|
|
|
|
if !ok {
|
|
return ProxyContext{}, errors.New("x-forwarded-host not found")
|
|
}
|
|
|
|
uri, ok := controller.getHeader(c, "x-forwarded-uri")
|
|
|
|
if !ok {
|
|
return ProxyContext{}, errors.New("x-forwarded-uri not found")
|
|
}
|
|
|
|
proto, ok := controller.getHeader(c, "x-forwarded-proto")
|
|
|
|
if !ok {
|
|
return ProxyContext{}, errors.New("x-forwarded-proto not found")
|
|
}
|
|
|
|
method := c.Request.Method
|
|
|
|
if method != http.MethodGet {
|
|
return ProxyContext{}, errors.New("method not allowed")
|
|
}
|
|
|
|
return ProxyContext{
|
|
Host: host,
|
|
Proto: proto,
|
|
Path: uri,
|
|
Method: method,
|
|
Type: ForwardAuth,
|
|
}, nil
|
|
}
|
|
|
|
func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyContext, error) {
|
|
xOriginalUrl, ok := controller.getHeader(c, "x-original-url")
|
|
|
|
if !ok {
|
|
return ProxyContext{}, errors.New("x-original-url not found")
|
|
}
|
|
|
|
url, err := url.Parse(xOriginalUrl)
|
|
|
|
if err != nil {
|
|
return ProxyContext{}, err
|
|
}
|
|
|
|
host := url.Host
|
|
proto := url.Scheme
|
|
path := url.Path
|
|
method := c.Request.Method
|
|
|
|
if method != http.MethodGet {
|
|
return ProxyContext{}, errors.New("method not allowed")
|
|
}
|
|
|
|
return ProxyContext{
|
|
Host: host,
|
|
Proto: proto,
|
|
Path: path,
|
|
Method: method,
|
|
Type: AuthRequest,
|
|
}, nil
|
|
}
|
|
|
|
func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyContext, error) {
|
|
proto, ok := controller.getHeader(c, "x-forwarded-proto")
|
|
|
|
if !ok {
|
|
return ProxyContext{}, errors.New("x-forwarded-proto not found")
|
|
}
|
|
|
|
host, ok := controller.getHeader(c, "host")
|
|
|
|
if !ok {
|
|
return ProxyContext{}, errors.New("host not found")
|
|
}
|
|
|
|
// Seems like we can't get the path?
|
|
|
|
// For envoy we need to support every method
|
|
method := c.Request.Method
|
|
|
|
return ProxyContext{
|
|
Host: host,
|
|
Proto: proto,
|
|
Method: method,
|
|
Type: ExtAuthz,
|
|
}, nil
|
|
}
|
|
|
|
func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
|
|
var req Proxy
|
|
|
|
err := c.BindUri(&req)
|
|
if err != nil {
|
|
return ProxyContext{}, err
|
|
}
|
|
|
|
var ctx ProxyContext
|
|
|
|
switch req.Proxy {
|
|
// For nginx we need to handle both forward_auth and auth_request extraction since it can be
|
|
// used either with something line nginx proxy manager with advanced config or with
|
|
// the kubernetes ingress controller
|
|
case "nginx":
|
|
tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction")
|
|
forwardAuthCtx, err := controller.getForwardAuthContext(c)
|
|
if err == nil {
|
|
tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions success using forward_auth")
|
|
ctx = forwardAuthCtx
|
|
} else {
|
|
tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions failed using forward_auth trying with auth_request")
|
|
authRequestCtx, err := controller.getAuthRequestContext(c)
|
|
if err != nil {
|
|
tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction")
|
|
return ProxyContext{}, err
|
|
}
|
|
ctx = authRequestCtx
|
|
}
|
|
case "envoy":
|
|
tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting ext_authz compatible extraction")
|
|
extAuthzCtx, err := controller.getExtAuthzContext(c)
|
|
if err != nil {
|
|
tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction")
|
|
return ProxyContext{}, err
|
|
}
|
|
ctx = extAuthzCtx
|
|
// By default we fallback to the forward_auth module which supports most proxies like traefik or caddy
|
|
default:
|
|
tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction")
|
|
forwardAuthCtx, err := controller.getForwardAuthContext(c)
|
|
if err != nil {
|
|
tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction")
|
|
return ProxyContext{}, err
|
|
}
|
|
ctx = forwardAuthCtx
|
|
}
|
|
|
|
// We don't care if the header is empty, we will just assume it's not a browser
|
|
userAgent, _ := controller.getHeader(c, "user-agent")
|
|
isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
|
|
|
|
if isBrowser {
|
|
tlog.App.Debug().Msg("Request identified as (most likely) coming from a browser")
|
|
} else {
|
|
tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client")
|
|
}
|
|
|
|
ctx.IsBrowser = isBrowser
|
|
return ctx, nil
|
|
}
|