outposts: separate CLI flow executor from ldap

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-07-17 21:24:11 +02:00
parent 9eb13c50e9
commit 36de302250
3 changed files with 174 additions and 112 deletions

158
internal/outpost/flow.go Normal file
View File

@ -0,0 +1,158 @@
package outpost
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
log "github.com/sirupsen/logrus"
"goauthentik.io/api"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak"
)
type StageComponent string
const (
StageIdentification = StageComponent("ak-stage-identification")
StagePassword = StageComponent("ak-stage-password")
StageAuthenticatorValidate = StageComponent("ak-stage-authenticator-validate")
StageAccessDenied = StageComponent("ak-stage-access-denied")
)
type FlowExecutor struct {
Params url.Values
Answers map[StageComponent]string
api *api.APIClient
flowSlug string
log *log.Entry
}
func NewFlowExecutor(flowSlug string, refConfig *api.Configuration) *FlowExecutor {
l := log.WithField("flow", flowSlug)
jar, err := cookiejar.New(nil)
if err != nil {
l.WithError(err).Warning("Failed to create cookiejar")
panic(err)
}
// Create new http client that also sets the correct ip
config := api.NewConfiguration()
config.Host = refConfig.Host
config.Scheme = refConfig.Scheme
config.UserAgent = constants.OutpostUserAgent()
config.HTTPClient = &http.Client{
Jar: jar,
Transport: ak.GetTLSTransport(),
}
apiClient := api.NewAPIClient(config)
return &FlowExecutor{
Params: url.Values{},
Answers: make(map[StageComponent]string),
api: apiClient,
flowSlug: flowSlug,
log: l,
}
}
func (fe *FlowExecutor) ApiClient() *api.APIClient {
return fe.api
}
type ChallengeInt interface {
GetComponent() string
GetType() api.ChallengeChoices
GetResponseErrors() map[string][]api.ErrorDetail
}
func (fe *FlowExecutor) CheckApplicationAccess(appSlug string) (bool, error) {
p, _, err := fe.api.CoreApi.CoreApplicationsCheckAccessRetrieve(context.Background(), appSlug).Execute()
if !p.Passing {
fe.log.Info("Access denied for user")
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check access: %w", err)
}
fe.log.Info("User has access")
return true, nil
}
func (fe *FlowExecutor) getAnswer(stage StageComponent) string {
if v, o := fe.Answers[stage]; o {
return v
}
return ""
}
func (fe *FlowExecutor) Execute() (bool, error) {
return fe.solveFlowChallenge(1)
}
func (fe *FlowExecutor) solveFlowChallenge(depth int) (bool, error) {
req := fe.api.FlowsApi.FlowsExecutorGet(context.Background(), fe.flowSlug).Query(fe.Params.Encode())
challenge, _, err := req.Execute()
if err != nil {
return false, errors.New("failed to get challenge")
}
ch := challenge.GetActualInstance().(ChallengeInt)
fe.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got challenge")
responseReq := fe.api.FlowsApi.FlowsExecutorSolve(context.Background(), fe.flowSlug).Query(fe.Params.Encode())
switch ch.GetComponent() {
case string(StageIdentification):
responseReq = responseReq.FlowChallengeResponseRequest(api.IdentificationChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewIdentificationChallengeResponseRequest(fe.getAnswer(StageIdentification))))
case string(StagePassword):
responseReq = responseReq.FlowChallengeResponseRequest(api.PasswordChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewPasswordChallengeResponseRequest(fe.getAnswer(StagePassword))))
case string(StageAuthenticatorValidate):
// We only support duo as authenticator, check if that's allowed
var deviceChallenge *api.DeviceChallenge
for _, devCh := range challenge.AuthenticatorValidationChallenge.DeviceChallenges {
if devCh.DeviceClass == string(api.DEVICECLASSESENUM_DUO) {
deviceChallenge = &devCh
}
}
if deviceChallenge == nil {
return false, errors.New("got ak-stage-authenticator-validate without duo")
}
devId, err := strconv.Atoi(deviceChallenge.DeviceUid)
if err != nil {
return false, errors.New("failed to convert duo device id to int")
}
devId32 := int32(devId)
inner := api.NewAuthenticatorValidationChallengeResponseRequest()
inner.Duo = &devId32
responseReq = responseReq.FlowChallengeResponseRequest(api.AuthenticatorValidationChallengeResponseRequestAsFlowChallengeResponseRequest(inner))
case string(StageAccessDenied):
return false, errors.New("got ak-stage-access-denied")
default:
return false, fmt.Errorf("unsupported challenge type %s", ch.GetComponent())
}
response, _, err := responseReq.Execute()
ch = response.GetActualInstance().(ChallengeInt)
fe.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got response")
switch ch.GetComponent() {
case string(StageAccessDenied):
return false, errors.New("got ak-stage-access-denied")
}
if ch.GetType() == "redirect" {
return true, nil
}
if err != nil {
return false, fmt.Errorf("failed to submit challenge %w", err)
}
if len(ch.GetResponseErrors()) > 0 {
for key, errs := range ch.GetResponseErrors() {
for _, err := range errs {
return false, fmt.Errorf("flow error %s: %s", key, err.String)
}
}
}
if depth >= 10 {
return false, errors.New("exceeded stage recursion depth")
}
return fe.solveFlowChallenge(depth + 1)
}

View File

@ -37,7 +37,7 @@ func (ls *LDAPServer) Refresh() error {
boundUsersMutex: sync.RWMutex{}, boundUsersMutex: sync.RWMutex{},
boundUsers: make(map[string]UserFlags), boundUsers: make(map[string]UserFlags),
s: ls, s: ls,
log: log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name), log: logger,
tlsServerName: provider.TlsServerName, tlsServerName: provider.TlsServerName,
uidStartNumber: *provider.UidStartNumber, uidStartNumber: *provider.UidStartNumber,
gidStartNumber: *provider.GidStartNumber, gidStartNumber: *provider.GidStartNumber,

View File

@ -4,18 +4,13 @@ import (
"context" "context"
"errors" "errors"
"net" "net"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
goldap "github.com/go-ldap/ldap/v3" goldap "github.com/go-ldap/ldap/v3"
"github.com/nmcclain/ldap" "github.com/nmcclain/ldap"
"goauthentik.io/api" "goauthentik.io/api"
"goauthentik.io/internal/constants" "goauthentik.io/internal/outpost"
"goauthentik.io/internal/outpost/ak"
) )
const ContextUserKey = "ak_user" const ContextUserKey = "ak_user"
@ -39,42 +34,29 @@ func (pi *ProviderInstance) getUsername(dn string) (string, error) {
} }
func (pi *ProviderInstance) Bind(username string, bindDN, bindPW string, conn net.Conn) (ldap.LDAPResultCode, error) { func (pi *ProviderInstance) Bind(username string, bindDN, bindPW string, conn net.Conn) (ldap.LDAPResultCode, error) {
jar, err := cookiejar.New(nil)
if err != nil {
pi.log.WithError(err).Warning("Failed to create cookiejar")
return ldap.LDAPResultOperationsError, nil
}
host, _, err := net.SplitHostPort(conn.RemoteAddr().String()) host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil { if err != nil {
pi.log.WithError(err).Warning("Failed to get remote IP") pi.log.WithError(err).Warning("Failed to get remote IP")
return ldap.LDAPResultOperationsError, nil return ldap.LDAPResultOperationsError, nil
} }
// Create new http client that also sets the correct ip fe := outpost.NewFlowExecutor(pi.flowSlug, pi.s.ac.Client.GetConfig())
config := api.NewConfiguration() fe.ApiClient().GetConfig().AddDefaultHeader("X-authentik-remote-ip", host)
config.Host = pi.s.ac.Client.GetConfig().Host fe.Params.Add("goauthentik.io/outpost/ldap", "true")
config.Scheme = pi.s.ac.Client.GetConfig().Scheme
config.UserAgent = constants.OutpostUserAgent()
config.HTTPClient = &http.Client{
Jar: jar,
Transport: ak.GetTLSTransport(),
}
config.AddDefaultHeader("X-authentik-remote-ip", host)
// create the API client, with the transport
apiClient := api.NewAPIClient(config)
params := url.Values{} fe.Answers[outpost.StageIdentification] = username
params.Add("goauthentik.io/outpost/ldap", "true") fe.Answers[outpost.StagePassword] = bindPW
passed, rerr := pi.solveFlowChallenge(username, bindPW, apiClient, params.Encode(), 1)
if rerr != ldap.LDAPResultSuccess { passed, err := fe.Execute()
pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to solve challenge") if err != nil {
return rerr, nil pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to execute flow")
return ldap.LDAPResultOperationsError, nil
} }
if !passed { if !passed {
return ldap.LDAPResultInvalidCredentials, nil return ldap.LDAPResultInvalidCredentials, nil
} }
p, _, err := apiClient.CoreApi.CoreApplicationsCheckAccessRetrieve(context.Background(), pi.appSlug).Execute() access, err := fe.CheckApplicationAccess(pi.appSlug)
if !p.Passing { if !access {
pi.log.WithField("bindDN", bindDN).Info("Access denied for user") pi.log.WithField("bindDN", bindDN).Info("Access denied for user")
return ldap.LDAPResultInsufficientAccessRights, nil return ldap.LDAPResultInsufficientAccessRights, nil
} }
@ -84,7 +66,7 @@ func (pi *ProviderInstance) Bind(username string, bindDN, bindPW string, conn ne
} }
pi.log.WithField("bindDN", bindDN).Info("User has access") pi.log.WithField("bindDN", bindDN).Info("User has access")
// Get user info to store in context // Get user info to store in context
userInfo, _, err := apiClient.CoreApi.CoreUsersMeRetrieve(context.Background()).Execute() userInfo, _, err := fe.ApiClient().CoreApi.CoreUsersMeRetrieve(context.Background()).Execute()
if err != nil { if err != nil {
pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to get user info") pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to get user info")
return ldap.LDAPResultOperationsError, nil return ldap.LDAPResultOperationsError, nil
@ -112,6 +94,7 @@ func (pi *ProviderInstance) SearchAccessCheck(user api.User) bool {
} }
return false return false
} }
func (pi *ProviderInstance) delayDeleteUserInfo(dn string) { func (pi *ProviderInstance) delayDeleteUserInfo(dn string) {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
quit := make(chan struct{}) quit := make(chan struct{})
@ -130,82 +113,3 @@ func (pi *ProviderInstance) delayDeleteUserInfo(dn string) {
} }
}() }()
} }
type ChallengeInt interface {
GetComponent() string
GetType() api.ChallengeChoices
GetResponseErrors() map[string][]api.ErrorDetail
}
func (pi *ProviderInstance) solveFlowChallenge(bindDN string, password string, client *api.APIClient, urlParams string, depth int) (bool, ldap.LDAPResultCode) {
req := client.FlowsApi.FlowsExecutorGet(context.Background(), pi.flowSlug).Query(urlParams)
challenge, _, err := req.Execute()
if err != nil {
pi.log.WithError(err).Warning("Failed to get challenge")
return false, ldap.LDAPResultOperationsError
}
ch := challenge.GetActualInstance().(ChallengeInt)
pi.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got challenge")
responseReq := client.FlowsApi.FlowsExecutorSolve(context.Background(), pi.flowSlug).Query(urlParams)
switch ch.GetComponent() {
case "ak-stage-identification":
responseReq = responseReq.FlowChallengeResponseRequest(api.IdentificationChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewIdentificationChallengeResponseRequest(bindDN)))
case "ak-stage-password":
responseReq = responseReq.FlowChallengeResponseRequest(api.PasswordChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewPasswordChallengeResponseRequest(password)))
case "ak-stage-authenticator-validate":
// We only support duo as authenticator, check if that's allowed
var deviceChallenge *api.DeviceChallenge
for _, devCh := range challenge.AuthenticatorValidationChallenge.DeviceChallenges {
if devCh.DeviceClass == string(api.DEVICECLASSESENUM_DUO) {
deviceChallenge = &devCh
}
}
if deviceChallenge == nil {
pi.log.Warning("got ak-stage-authenticator-validate without duo")
return false, ldap.LDAPResultOperationsError
}
devId, err := strconv.Atoi(deviceChallenge.DeviceUid)
if err != nil {
pi.log.Warning("failed to convert duo device id to int")
return false, ldap.LDAPResultOperationsError
}
devId32 := int32(devId)
inner := api.NewAuthenticatorValidationChallengeResponseRequest()
inner.Duo = &devId32
responseReq = responseReq.FlowChallengeResponseRequest(api.AuthenticatorValidationChallengeResponseRequestAsFlowChallengeResponseRequest(inner))
case "ak-stage-access-denied":
pi.log.Info("got ak-stage-access-denied")
return false, ldap.LDAPResultInsufficientAccessRights
default:
pi.log.WithField("component", ch.GetComponent()).Warning("unsupported challenge type")
return false, ldap.LDAPResultOperationsError
}
response, _, err := responseReq.Execute()
ch = response.GetActualInstance().(ChallengeInt)
pi.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got response")
switch ch.GetComponent() {
case "ak-stage-access-denied":
pi.log.Info("got ak-stage-access-denied")
return false, ldap.LDAPResultInsufficientAccessRights
}
if ch.GetType() == "redirect" {
return true, ldap.LDAPResultSuccess
}
if err != nil {
pi.log.WithError(err).Warning("Failed to submit challenge")
return false, ldap.LDAPResultOperationsError
}
if len(ch.GetResponseErrors()) > 0 {
for key, errs := range ch.GetResponseErrors() {
for _, err := range errs {
pi.log.WithField("key", key).WithField("code", err.Code).WithField("msg", err.String).Warning("Flow error")
return false, ldap.LDAPResultInsufficientAccessRights
}
}
}
if depth >= 10 {
pi.log.Warning("exceeded stage recursion depth")
return false, ldap.LDAPResultOperationsError
}
return pi.solveFlowChallenge(bindDN, password, client, urlParams, depth+1)
}