outposts: separate CLI flow executor from ldap
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		
							
								
								
									
										158
									
								
								internal/outpost/flow.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								internal/outpost/flow.go
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
| @ -37,7 +37,7 @@ func (ls *LDAPServer) Refresh() error { | ||||
| 			boundUsersMutex:     sync.RWMutex{}, | ||||
| 			boundUsers:          make(map[string]UserFlags), | ||||
| 			s:                   ls, | ||||
| 			log:                 log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name), | ||||
| 			log:                 logger, | ||||
| 			tlsServerName:       provider.TlsServerName, | ||||
| 			uidStartNumber:      *provider.UidStartNumber, | ||||
| 			gidStartNumber:      *provider.GidStartNumber, | ||||
|  | ||||
| @ -4,18 +4,13 @@ import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"net/http/cookiejar" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	goldap "github.com/go-ldap/ldap/v3" | ||||
| 	"github.com/nmcclain/ldap" | ||||
| 	"goauthentik.io/api" | ||||
| 	"goauthentik.io/internal/constants" | ||||
| 	"goauthentik.io/internal/outpost/ak" | ||||
| 	"goauthentik.io/internal/outpost" | ||||
| ) | ||||
|  | ||||
| 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) { | ||||
| 	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()) | ||||
| 	if err != nil { | ||||
| 		pi.log.WithError(err).Warning("Failed to get remote IP") | ||||
| 		return ldap.LDAPResultOperationsError, nil | ||||
| 	} | ||||
|  | ||||
| 	// Create new http client that also sets the correct ip | ||||
| 	config := api.NewConfiguration() | ||||
| 	config.Host = pi.s.ac.Client.GetConfig().Host | ||||
| 	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) | ||||
| 	fe := outpost.NewFlowExecutor(pi.flowSlug, pi.s.ac.Client.GetConfig()) | ||||
| 	fe.ApiClient().GetConfig().AddDefaultHeader("X-authentik-remote-ip", host) | ||||
| 	fe.Params.Add("goauthentik.io/outpost/ldap", "true") | ||||
|  | ||||
| 	params := url.Values{} | ||||
| 	params.Add("goauthentik.io/outpost/ldap", "true") | ||||
| 	passed, rerr := pi.solveFlowChallenge(username, bindPW, apiClient, params.Encode(), 1) | ||||
| 	if rerr != ldap.LDAPResultSuccess { | ||||
| 		pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to solve challenge") | ||||
| 		return rerr, nil | ||||
| 	fe.Answers[outpost.StageIdentification] = username | ||||
| 	fe.Answers[outpost.StagePassword] = bindPW | ||||
|  | ||||
| 	passed, err := fe.Execute() | ||||
| 	if err != nil { | ||||
| 		pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to execute flow") | ||||
| 		return ldap.LDAPResultOperationsError, nil | ||||
| 	} | ||||
| 	if !passed { | ||||
| 		return ldap.LDAPResultInvalidCredentials, nil | ||||
| 	} | ||||
| 	p, _, err := apiClient.CoreApi.CoreApplicationsCheckAccessRetrieve(context.Background(), pi.appSlug).Execute() | ||||
| 	if !p.Passing { | ||||
| 	access, err := fe.CheckApplicationAccess(pi.appSlug) | ||||
| 	if !access { | ||||
| 		pi.log.WithField("bindDN", bindDN).Info("Access denied for user") | ||||
| 		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") | ||||
| 	// 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 { | ||||
| 		pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to get user info") | ||||
| 		return ldap.LDAPResultOperationsError, nil | ||||
| @ -112,6 +94,7 @@ func (pi *ProviderInstance) SearchAccessCheck(user api.User) bool { | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| func (pi *ProviderInstance) delayDeleteUserInfo(dn string) { | ||||
| 	ticker := time.NewTicker(30 * time.Second) | ||||
| 	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) | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer