package auth

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/hashicorp/boundary/globals"
	"github.com/hashicorp/boundary/internal/gen/controller/api/resources/scopes"
	"github.com/hashicorp/boundary/internal/gen/controller/tokens"
	"github.com/hashicorp/boundary/internal/kms"
	"github.com/hashicorp/boundary/internal/perms"
	"github.com/hashicorp/boundary/internal/servers/controller/common"
	"github.com/hashicorp/boundary/internal/servers/controller/handlers"
	"github.com/hashicorp/boundary/internal/types/action"
	"github.com/hashicorp/boundary/internal/types/resource"
	"github.com/hashicorp/boundary/internal/types/scope"
	"github.com/hashicorp/boundary/sdk/recovery"
	"github.com/hashicorp/go-hclog"
	wrapping "github.com/hashicorp/go-kms-wrapping"
	"github.com/kr/pretty"
	"github.com/mr-tron/base58"
	"google.golang.org/protobuf/proto"
)

type TokenFormat int

const (
	// We weren't given one or couldn't parse it
	AuthTokenTypeUnknown TokenFormat = iota

	// Came in via the Authentication: Bearer header
	AuthTokenTypeBearer

	// Came in via split cookies
	AuthTokenTypeSplitCookie

	// It's of recovery type
	AuthTokenTypeRecoveryKms
)

type key int

var verifierKey key

// RequestInfo contains request parameters necessary for checking authn/authz
type RequestInfo struct {
	Path           string
	Method         string
	PublicId       string
	EncryptedToken string
	Token          string
	TokenFormat    TokenFormat

	// The following are useful for tests
	scopeIdOverride      string
	userIdOverride       string
	DisableAuthzFailures bool
	DisableAuthEntirely  bool
}

type VerifyResults struct {
	UserId      string
	AuthTokenId string
	Error       error
	Scope       *scopes.ScopeInfo

	// Used for additional verification
	v *verifier
}

type verifier struct {
	logger          hclog.Logger
	iamRepoFn       common.IamRepoFactory
	authTokenRepoFn common.AuthTokenRepoFactory
	serversRepoFn   common.ServersRepoFactory
	kms             *kms.Kms
	requestInfo     RequestInfo
	res             *perms.Resource
	act             action.Type
	ctx             context.Context
	acl             perms.ACL
}

// NewVerifierContext creates a context that carries a verifier object from the
// HTTP handlers to the gRPC service handlers. It should only be created in the
// HTTP handler and should exist for every request that reaches the service
// handlers.
func NewVerifierContext(ctx context.Context,
	logger hclog.Logger,
	iamRepoFn common.IamRepoFactory,
	authTokenRepoFn common.AuthTokenRepoFactory,
	serversRepoFn common.ServersRepoFactory,
	kms *kms.Kms,
	requestInfo RequestInfo) context.Context {
	return context.WithValue(ctx, verifierKey, &verifier{
		logger:          logger,
		iamRepoFn:       iamRepoFn,
		authTokenRepoFn: authTokenRepoFn,
		serversRepoFn:   serversRepoFn,
		kms:             kms,
		requestInfo:     requestInfo,
	})
}

// Verify takes in a context that has expected parameters as values and runs an
// authn/authz check. It returns a user ID, the scope ID for the request (which
// may come from the URL and may come from the token) and whether or not to
// proceed, e.g. whether the authn/authz check resulted in failure. If an error
// occurs it's logged to the system log.
func Verify(ctx context.Context, opt ...Option) (ret VerifyResults) {
	ret.Error = handlers.ForbiddenError()
	v, ok := ctx.Value(verifierKey).(*verifier)
	if !ok {
		// We don't have a logger yet and this should never happen in any
		// context we won't catch in tests
		panic("no verifier information found in context")
	}

	ret.v = v

	v.ctx = ctx

	opts := getOpts(opt...)

	ret.Scope = new(scopes.ScopeInfo)
	if v.requestInfo.DisableAuthEntirely {
		ret.Scope.Id = v.requestInfo.scopeIdOverride
		if ret.Scope.Id == "" {
			ret.Scope.Id = opts.withScopeId
		}
		switch {
		case ret.Scope.Id == "global":
			ret.Scope.Type = "global"
		case strings.HasPrefix(ret.Scope.Id, scope.Org.Prefix()):
			ret.Scope.Type = scope.Org.String()
		case strings.HasPrefix(ret.Scope.Id, scope.Project.Prefix()):
			ret.Scope.Type = scope.Project.String()
		}
		ret.UserId = v.requestInfo.userIdOverride
		ret.Error = nil
		return
	}

	v.act = opts.withAction
	v.res = &perms.Resource{
		ScopeId: opts.withScopeId,
		Id:      opts.withId,
		Pin:     opts.withPin,
		Type:    opts.withType,
	}
	// Global scope has no parent ID; account for this
	if opts.withId == scope.Global.String() && opts.withType == resource.Scope {
		v.res.ScopeId = scope.Global.String()
	}

	if v.requestInfo.EncryptedToken != "" {
		v.decryptToken()
	}

	var authResults perms.ACLResults
	var err error
	authResults, ret.UserId, ret.Scope, v.acl, err = v.performAuthCheck()
	if err != nil {
		v.logger.Error("error performing authn/authz check", "error", err)
		return
	}

	ret.AuthTokenId = v.requestInfo.PublicId
	if !authResults.Allowed {
		if v.requestInfo.DisableAuthzFailures {
			ret.Error = nil
			// TODO: Decide whether to remove this
			v.logger.Info("failed authz info for request", "resource", pretty.Sprint(v.res), "user_id", ret.UserId, "action", v.act.String())
		} else {
			// If the anon user was used (either no token, or invalid (perhaps
			// expired) token), return a 401. That way if it's an authn'd user
			// that is not authz'd we'll return 403 to be explicit.
			if ret.UserId == "u_anon" {
				ret.Error = handlers.UnauthenticatedError()
			}
			return
		}
	}

	ret.Error = nil
	return
}

// AdditionalVerification is used to perform checks of additional resources for
// actions that need to touch more than one.
func (r *VerifyResults) AdditionalVerification(ctx context.Context, opt ...Option) (ret VerifyResults) {
	v := r.v

	ret.Error = handlers.ForbiddenError()

	// Set other parameters the same to start with
	ret.Scope = r.Scope
	ret.UserId = r.UserId
	ret.AuthTokenId = r.AuthTokenId
	ret.v = r.v

	opts := getOpts(opt...)

	act := opts.withAction
	res := perms.Resource{
		ScopeId: opts.withScopeId,
		Id:      opts.withId,
		Pin:     opts.withPin,
		Type:    opts.withType,
	}
	// Global scope has no parent ID; account for this
	if opts.withId == scope.Global.String() && opts.withType == resource.Scope {
		res.ScopeId = scope.Global.String()
	}

	// Only perform lookup if it's actually different, otherwise use cached info
	if res.ScopeId != r.Scope.Id {
		iamRepo, err := v.iamRepoFn()
		if err != nil {
			v.logger.Error("additional verification: failed to get iam repo", "error", err)
			return
		}

		// Look up scope details to return. We can skip a lookup when using the
		// global scope
		switch res.ScopeId {
		case "global":
			ret.Scope = &scopes.ScopeInfo{
				Id:            scope.Global.String(),
				Type:          scope.Global.String(),
				Name:          scope.Global.String(),
				Description:   "Global Scope",
				ParentScopeId: "",
			}

		default:
			scp, err := iamRepo.LookupScope(v.ctx, v.res.ScopeId)
			if err != nil {
				v.logger.Error("additional verification: failed to get look up scope", "error", err)
				return
			}
			if scp == nil {
				v.logger.Error("additional verification: non-existent scope", "error", err)
				return
			}
			ret.Scope = &scopes.ScopeInfo{
				Id:            scp.GetPublicId(),
				Type:          scp.GetType(),
				Name:          scp.GetName(),
				Description:   scp.GetDescription(),
				ParentScopeId: scp.GetParentId(),
			}
		}
	}

	// Always allowed
	if v.requestInfo.TokenFormat == AuthTokenTypeRecoveryKms {
		ret.Error = nil
		return
	}

	aclResults := v.acl.Allowed(res, act)

	if !aclResults.Allowed {
		if v.requestInfo.DisableAuthzFailures {
			ret.Error = nil
			// TODO: Decide whether to remove this
			v.logger.Info("failed authz info for request", "resource", pretty.Sprint(v.res), "user_id", ret.UserId, "action", v.act.String())
		} else {
			// If the anon user was used (either no token, or invalid (perhaps
			// expired) token), return a 401. That way if it's an authn'd user
			// that is not authz'd we'll return 403 to be explicit.
			if ret.UserId == "u_anon" {
				ret.Error = handlers.UnauthenticatedError()
			}
			return
		}
	}

	ret.Error = nil
	return
}

func (v verifier) performAuthCheck() (aclResults perms.ACLResults, userId string, scopeInfo *scopes.ScopeInfo, retAcl perms.ACL, retErr error) {
	// Ensure we return an error by default if we forget to set this somewhere
	retErr = errors.New("unknown")
	// Make the linter happy
	_ = retErr
	scopeInfo = new(scopes.ScopeInfo)
	userId = "u_anon"

	// Validate the token and fetch the corresponding user ID
	switch v.requestInfo.TokenFormat {
	case AuthTokenTypeUnknown:
		// Nothing; remain as the anonymous user

	case AuthTokenTypeRecoveryKms:
		// We validated the encrypted token in decryptToken and handled the
		// nonces there, so just set the user
		userId = "u_recovery"

	case AuthTokenTypeBearer, AuthTokenTypeSplitCookie:
		if v.requestInfo.Token == "" {
			// This will end up staying as the anonymous user
			break
		}
		tokenRepo, err := v.authTokenRepoFn()
		if err != nil {
			retErr = fmt.Errorf("perform auth check: failed to get authtoken repo: %w", err)
			return
		}
		at, err := tokenRepo.ValidateToken(v.ctx, v.requestInfo.PublicId, v.requestInfo.Token)
		if err != nil {
			// Continue as the anonymous user as maybe this token is expired but
			// we can still perform the action
			v.logger.Error("perform auth check: error validating token; continuing as anonymous user", "error", err)
			break
		}
		if at != nil {
			userId = at.GetIamUserId()
			if userId == "" {
				v.logger.Warn("perform auth check: valid token did not map to a user, likely because no account is associated with the user any longer; continuing as u_anon", "token_id", at.GetPublicId())
				userId = "u_anon"
			}
		}
	}

	iamRepo, err := v.iamRepoFn()
	if err != nil {
		retErr = fmt.Errorf("perform auth check: failed to get iam repo: %w", err)
		return
	}

	// Look up scope details to return. We can skip a lookup when using the
	// global scope
	switch v.res.ScopeId {
	case "global":
		scopeInfo = &scopes.ScopeInfo{
			Id:            scope.Global.String(),
			Type:          scope.Global.String(),
			Name:          scope.Global.String(),
			Description:   "Global Scope",
			ParentScopeId: "",
		}

	default:
		scp, err := iamRepo.LookupScope(v.ctx, v.res.ScopeId)
		if err != nil {
			retErr = fmt.Errorf("perform auth check: failed to lookup scope: %w", err)
			return
		}
		if scp == nil {
			retErr = fmt.Errorf("perform auth check: non-existent scope %q", v.res.ScopeId)
			return
		}
		scopeInfo = &scopes.ScopeInfo{
			Id:            scp.GetPublicId(),
			Type:          scp.GetType(),
			Name:          scp.GetName(),
			Description:   scp.GetDescription(),
			ParentScopeId: scp.GetParentId(),
		}
	}

	// At this point we don't need to look up grants since it's automatically allowed
	if v.requestInfo.TokenFormat == AuthTokenTypeRecoveryKms {
		aclResults.Allowed = true
		retErr = nil
		return
	}

	var parsedGrants []perms.Grant
	var grantPairs []perms.GrantPair

	// Fetch and parse grants for this user ID (which may include grants for
	// u_anon and u_auth)
	grantPairs, err = iamRepo.GrantsForUser(v.ctx, userId)
	if err != nil {
		retErr = fmt.Errorf("perform auth check: failed to query for user grants: %w", err)
		return
	}
	parsedGrants = make([]perms.Grant, 0, len(grantPairs))
	for _, pair := range grantPairs {
		parsed, err := perms.Parse(pair.ScopeId, userId, pair.Grant)
		if err != nil {
			retErr = fmt.Errorf("perform auth check: failed to parse grant %#v: %w", pair.Grant, err)
			return
		}
		parsedGrants = append(parsedGrants, parsed)
	}

	retAcl = perms.NewACL(parsedGrants...)
	aclResults = retAcl.Allowed(*v.res, v.act)
	retErr = nil
	return
}

// GetTokenFromRequest pulls the token from either the Authorization header or
// split cookies and parses it. If it cannot be parsed successfully, the issue
// is logged and we return blank, so logic will continue as the anonymous user.
// The public ID and _encrypted_ token are returned along with the token format.
func GetTokenFromRequest(logger hclog.Logger, kmsCache *kms.Kms, req *http.Request) (string, string, TokenFormat) {
	// First, get the token, either from the authorization header or from split
	// cookies
	var receivedTokenType TokenFormat
	var fullToken string
	if authHeader := req.Header.Get("Authorization"); authHeader != "" {
		headerSplit := strings.SplitN(strings.TrimSpace(authHeader), " ", 2)
		if len(headerSplit) == 2 && strings.EqualFold(strings.TrimSpace(headerSplit[0]), "bearer") {
			receivedTokenType = AuthTokenTypeBearer
			fullToken = strings.TrimSpace(headerSplit[1])
		}
	}
	if receivedTokenType != AuthTokenTypeBearer {
		var httpCookiePayload string
		var jsCookiePayload string
		if hc, err := req.Cookie(handlers.HttpOnlyCookieName); err == nil {
			httpCookiePayload = hc.Value
		}
		if jc, err := req.Cookie(handlers.JsVisibleCookieName); err == nil {
			jsCookiePayload = jc.Value
		}
		if httpCookiePayload != "" && jsCookiePayload != "" {
			receivedTokenType = AuthTokenTypeSplitCookie
			fullToken = jsCookiePayload + httpCookiePayload
		}
	}

	if receivedTokenType == AuthTokenTypeUnknown || fullToken == "" {
		// We didn't find auth info or a client screwed up and put in a blank
		// header instead of nothing at all, so return blank which will indicate
		// the anonymouse user
		return "", "", AuthTokenTypeUnknown
	}

	if strings.HasPrefix(fullToken, "r_") {
		return "", fullToken, AuthTokenTypeRecoveryKms
	}

	splitFullToken := strings.Split(fullToken, "_")
	if len(splitFullToken) != 3 {
		logger.Trace("get token from request: unexpected number of segments in token; continuing as anonymous user", "expected", 3, "found", len(splitFullToken))
		return "", "", AuthTokenTypeUnknown
	}

	publicId := strings.Join(splitFullToken[0:2], "_")
	encryptedToken := splitFullToken[2]

	return publicId, encryptedToken, receivedTokenType
}

func (v *verifier) decryptToken() {
	switch v.requestInfo.TokenFormat {
	case AuthTokenTypeUnknown:
		// Nothing to decrypt
		return

	case AuthTokenTypeBearer, AuthTokenTypeSplitCookie:
		if v.kms == nil {
			v.logger.Trace("decrypt token: no KMS object available to authz system")
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		tokenRepo, err := v.authTokenRepoFn()
		if err != nil {
			v.logger.Warn("decrypt bearer token: failed to get authtoken repo", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		at, err := tokenRepo.LookupAuthToken(v.ctx, v.requestInfo.PublicId)
		if err != nil {
			v.logger.Trace("decrypt bearer token: failed to look up auth token by public ID", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		if at == nil {
			v.logger.Trace("decrypt bearer token: nil result from looking up auth token by public ID")
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		tokenWrapper, err := v.kms.GetWrapper(v.ctx, at.GetScopeId(), kms.KeyPurposeTokens)
		if err != nil {
			v.logger.Warn("decrypt bearer token: unable to get wrapper for tokens; continuing as anonymous user", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		version := v.requestInfo.EncryptedToken[0:len(globals.ServiceTokenV1)]
		switch version {
		case globals.ServiceTokenV1:
		default:
			v.logger.Trace("decrypt bearer token: unknown token encryption version; continuing as anonymous user", "version", version)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		marshaledToken, err := base58.FastBase58Decoding(v.requestInfo.EncryptedToken[len(globals.ServiceTokenV1):])
		if err != nil {
			v.logger.Trace("decrypt bearer token: error unmarshaling base58 token; continuing as anonymous user", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		blobInfo := new(wrapping.EncryptedBlobInfo)
		if err := proto.Unmarshal(marshaledToken, blobInfo); err != nil {
			v.logger.Trace("decrypt bearer token: error decoding encrypted token; continuing as anonymous user", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		s1Bytes, err := tokenWrapper.Decrypt(v.ctx, blobInfo, []byte(v.requestInfo.PublicId))
		if err != nil {
			v.logger.Trace("decrypt bearer token: error decrypting encrypted token; continuing as anonymous user", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		var s1Info tokens.S1TokenInfo
		if err := proto.Unmarshal(s1Bytes, &s1Info); err != nil {
			v.logger.Trace("decrypt bearer token: error unmarshaling token info; continuing as anonymous user", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		if v.requestInfo.TokenFormat == AuthTokenTypeUnknown || s1Info.Token == "" || v.requestInfo.PublicId == "" {
			v.logger.Trace("decrypt bearer token: after parsing, could not find valid token; continuing as anonymous user")
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}

		v.requestInfo.Token = s1Info.Token
		return

	case AuthTokenTypeRecoveryKms:
		if v.kms == nil {
			v.logger.Trace("decrypt recovery token: no KMS object available to authz system")
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		wrapper := v.kms.GetExternalWrappers().Recovery()
		if wrapper == nil {
			v.logger.Trace("decrypt recovery token: no recovery KMS is available")
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		info, err := recovery.ParseRecoveryToken(v.ctx, wrapper, v.requestInfo.EncryptedToken)
		if err != nil {
			v.logger.Trace("decrypt recovery token: error parsing and validating recovery token", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		// If we add the validity period to the creation time (which we've
		// verified is before the current time, with a minute of fudging), and
		// it's before now, it's expired and might be a replay.
		if info.CreationTime.Add(globals.RecoveryTokenValidityPeriod).Before(time.Now()) {
			v.logger.Warn("decrypt recovery token: recovery token has expired (possible replay attack)")
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		repo, err := v.serversRepoFn()
		if err != nil {
			v.logger.Trace("decrypt recovery token: error fetching servers repo", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		if err := repo.AddRecoveryNonce(v.ctx, info.Nonce); err != nil {
			v.logger.Warn("decrypt recovery token: error adding nonce to database (possible replay attack)", "error", err)
			v.requestInfo.TokenFormat = AuthTokenTypeUnknown
			return
		}
		v.logger.Warn("recovery KMS was used to authorize a call", "url", v.requestInfo.Path, "method", v.requestInfo.Method)
	}
}
