* feature (auth): required schema changes for auth ldap method (#2669) chore (auth/ldap): move schema changes to next avail migration number * feature (auth/ldap): define AuthMethod and all its value objects (#2703) * feature (auth/ldap): storage protos * feature (auth/ldap): define AuthMethod and all its value objects * feature (auth/ldap): add repo and reading an auth method (#2718) * feature (auth/ldap): add Repository.CreateAuthMethod(...) and Repository.DeleteAuthMethod(...) (#2724) * feature (auth/ldap): add Repository.UpdateAuthMethod(...) (#2739) * feature (auth/ldap): add Account * feature (auth/ldap): add AuthMethod.EnableGroups * fix (auth/ldap): refactor AuthMethod.oplog to enforce proper constraints * feature (auth/ldap): add Account repo functions * refactor (auth/ldap): remove entry attributes from account Realized the entry attributes could have absolutely anything in them (including binary data) and since we absolutely don't have to have them there's just no reason to take on the risk. * feature (auth/ldap): add AuthMethod.UseTokenGroups * feature (auth/ldap): add Authenticate(...) * refactor (auth/ldap): ensure options take a context as the 1st parameter * feature (auth/ldap): add Account attribute maps * chore (auth/ldap): make fmt deltas * feature (auth/ldap): add managed groups (#2760) * tests (auth/ldap): add missing unit test to Repository.DeleteAccount(...) Add bits to test the delete operation when you're not able to generate oplog metadata * feature (auth/ldap): add managed groups fixup! feature (auth/ldap): add managed groups (#2760) * feature (auth/ldap): service proto definition (#2761) * feature (handlers/authmethods): add handlers for ldap auth method operations (#2794) * feature (auth/ldap): add Account attribute maps * chore (auth/ldap): update cap/ldap to latest version * feature (auth/ldap): add ldap api generation definitions * feature (authmethods): add ldap repo NewService(...) * feature (authmethods/ldap): add proper mask_mapping to protobufs * feature (authmethods): add support to get an ldap auth method * refactor (auth/ldap): export TestGenerateCA(...) * feature (authmethods): add support to create an ldap auth method * feature (authmethods): add support to delete an ldap auth method * feature (authmethods): add support to list ldap auth methods * refactor (auth/ldap): make urls optional for NewAuthMethod(...) * refactor (auth/ldap): export ldap.TestInvalidPem * chore: make fmt changes * fix (auth/ldap): properly handle group search config Add constraints and tests to ensure when an ldap AuthMethod.EnableGroups is true, and UseTokenGroups is false; that there's a GroupDn configured for finding a user's associated groups * feature (authmethods): add support to update an ldap auth methods * chore (db/ldap): tmp mv migrations so there's no conflict with ongoing work * feature (verifier): add ldap auth method to verifier bits * fix (controller): prevent panic when controller stops when there's no listener * feature (authmethods): add support to authenticate via ldap auth methods * chore (migrations): fix whitespace in stmt * chore: fmt fixup * tests (auth/ldap): invalid err msg * feature (cli/authmethods): add support for ldap auth-methods CRUD and authenticate (#2810) * feature (authmethods): add CLI support for ldap auth methods CRUD * tests (api/auth): ldap auth method classification tests * feature (authmethods): add CLI support for ldap auth authenticate * feature (auth/ldap): set request timeouts for ldap server connections * feature (handlers/authmethods): handle u_anon listing properly. * feature (account/handers): ldap account and managed group CRUDL APIs (#2852) * feature (auth/ldap) add repository Listing of ManagedGroupMemberAccount * feature (controller/handlers): add ldapRepoFn to accounts service * feature (auth/ldap) register ldap managed group subtype * feature (account/handlers): ldap account CRUDL APIs * feature (controller/handlers): add ldapRepo to managed groups service * feature (account/handlers): ldap managed group CRUDL APIs * docs (domain): add LDAP accounts, auth-methods and managed groups (#2857) * feature (ldap/cli) add ldap accounts and managed groups CRUDL commands (#2856) * fix (handlers/authmethods): fix ldap authorized actions (#2892) * feature (cli/ldap/authenticate): use primary auth method if none is provided (#2890) * fix (auth/ldap): support setting the state attribute * feature (cli/ldap/authenticate): use primary auth method if none is provided * feature (wh/ldap) add tests for new ldap auth method and accounts (#2919) * refactor (migrations/ldap): mv to correct directory * chore: add copyright headers * fix (api/authmethods/ldap): renumber new LdapAuthMethodAttributes field * fix (auth/ldap): allow the auth method state to be updated (#2951) * chore: update sdk and api versions for llb - this is tmp until merging * tests (managed groups): add required errContains for new test
937 lines
33 KiB
Go
937 lines
33 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/boundary/api/recovery"
|
|
"github.com/hashicorp/boundary/globals"
|
|
"github.com/hashicorp/boundary/internal/auth"
|
|
"github.com/hashicorp/boundary/internal/auth/ldap"
|
|
"github.com/hashicorp/boundary/internal/auth/oidc"
|
|
"github.com/hashicorp/boundary/internal/auth/password"
|
|
"github.com/hashicorp/boundary/internal/daemon/controller/common"
|
|
"github.com/hashicorp/boundary/internal/daemon/controller/handlers"
|
|
"github.com/hashicorp/boundary/internal/errors"
|
|
authpb "github.com/hashicorp/boundary/internal/gen/controller/auth"
|
|
"github.com/hashicorp/boundary/internal/gen/controller/tokens"
|
|
"github.com/hashicorp/boundary/internal/iam"
|
|
"github.com/hashicorp/boundary/internal/kms"
|
|
"github.com/hashicorp/boundary/internal/observability/event"
|
|
"github.com/hashicorp/boundary/internal/perms"
|
|
"github.com/hashicorp/boundary/internal/requests"
|
|
"github.com/hashicorp/boundary/internal/server"
|
|
"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/internal/types/subtypes"
|
|
"github.com/hashicorp/boundary/internal/util"
|
|
"github.com/hashicorp/boundary/internal/util/template"
|
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/scopes"
|
|
wrapping "github.com/hashicorp/go-kms-wrapping/v2"
|
|
"github.com/mr-tron/base58"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
type TokenFormat uint32
|
|
|
|
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
|
|
|
|
type VerifyResults struct {
|
|
UserData template.Data
|
|
// This is copied out from UserData above but used to avoid nil checks in
|
|
// lots of places that embed this value
|
|
UserId string
|
|
AuthTokenId string
|
|
Error error
|
|
Scope *scopes.ScopeInfo
|
|
|
|
// AuthenticatedFinished means that the request has passed through the
|
|
// authentication system successfully. This does _not_ indicate whether a
|
|
// token was provided on the request. Requests for `u_anon` will still have
|
|
// this set true! This is because if a request has a token that is invalid,
|
|
// we fall back to `u_anon` because the request may still be allowed for any
|
|
// anonymous user; it simply fails to validate for and look up grants for an
|
|
// actual known user.
|
|
//
|
|
// A good example is when running dev mode twice. The first time you can
|
|
// authenticate and get a token which is saved by the token helper. The
|
|
// second time, you run a command and it reads the token from the helper.
|
|
// That token is no longer valid, but if the action is granted to `u_anon`
|
|
// the action should still succeed. What happens internally is that the
|
|
// token fails to look up a non-anonymous user, so we fallback to the
|
|
// anonymous user, which is the default.
|
|
//
|
|
// If you want to know if the request had a valid token provided, use a
|
|
// switch on UserId. Anything that isn't `u_anon` will have to have had a
|
|
// valid token provided. And a valid token will never fall back to `u_anon`.
|
|
AuthenticationFinished bool
|
|
|
|
// RoundTripValue can be set to allow the function performing authentication
|
|
// (often accompanied by lookup(s)) to return a result of that lookup to the
|
|
// calling function. It is opaque to this package.
|
|
RoundTripValue any
|
|
|
|
// Used for additional verification
|
|
v *verifier
|
|
}
|
|
|
|
type verifier struct {
|
|
iamRepoFn common.IamRepoFactory
|
|
authTokenRepoFn common.AuthTokenRepoFactory
|
|
serversRepoFn common.ServersRepoFactory
|
|
passwordAuthRepoFn common.PasswordAuthRepoFactory
|
|
oidcAuthRepoFn common.OidcAuthRepoFactory
|
|
ldapAuthRepoFn common.LdapAuthRepoFactory
|
|
kms *kms.Kms
|
|
requestInfo *authpb.RequestInfo
|
|
res *perms.Resource
|
|
act action.Type
|
|
ctx context.Context
|
|
acl perms.ACL
|
|
}
|
|
|
|
// TODO (jefferai 10/2022): NewVerifierContextWithAccounts performs the function
|
|
// of NewVerifierContext (see the docs for that function) but with extra
|
|
// parameters that can be used to look up account information. This is not
|
|
// intended to be a long-lived function; see
|
|
// https://hashicorp.atlassian.net/browse/ICU-6571 and
|
|
// https://hashicorp.atlassian.net/browse/ICU-6572
|
|
//
|
|
// This is being added for a quick turnaround purpose and to avoid making large
|
|
// numbers of changes to tests when we may do a much bigger refactor; when those
|
|
// items are addressed this can be removed.
|
|
func NewVerifierContextWithAccounts(ctx context.Context,
|
|
iamRepoFn common.IamRepoFactory,
|
|
authTokenRepoFn common.AuthTokenRepoFactory,
|
|
serversRepoFn common.ServersRepoFactory,
|
|
passwordAuthRepoFn common.PasswordAuthRepoFactory,
|
|
oidcAuthRepoFn common.OidcAuthRepoFactory,
|
|
ldapAuthRepoFn common.LdapAuthRepoFactory,
|
|
kms *kms.Kms,
|
|
requestInfo *authpb.RequestInfo,
|
|
) context.Context {
|
|
return context.WithValue(ctx, verifierKey, &verifier{
|
|
iamRepoFn: iamRepoFn,
|
|
authTokenRepoFn: authTokenRepoFn,
|
|
serversRepoFn: serversRepoFn,
|
|
passwordAuthRepoFn: passwordAuthRepoFn,
|
|
oidcAuthRepoFn: oidcAuthRepoFn,
|
|
ldapAuthRepoFn: ldapAuthRepoFn,
|
|
kms: kms,
|
|
requestInfo: requestInfo,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
iamRepoFn common.IamRepoFactory,
|
|
authTokenRepoFn common.AuthTokenRepoFactory,
|
|
serversRepoFn common.ServersRepoFactory,
|
|
kms *kms.Kms,
|
|
requestInfo *authpb.RequestInfo,
|
|
) context.Context {
|
|
return NewVerifierContextWithAccounts(ctx, iamRepoFn, authTokenRepoFn, serversRepoFn, nil, nil, nil, kms, 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) {
|
|
const op = "auth.Verify"
|
|
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
|
|
|
|
ea := &event.Auth{}
|
|
defer event.WriteAudit(ctx, op, event.WithAuth(ea))
|
|
|
|
opts := getOpts(opt...)
|
|
|
|
ret.Scope = new(scopes.ScopeInfo)
|
|
|
|
// Note: we don't call RequestContextFromCtx here because that performs a
|
|
// SelfOrDefault. That's useful in the handlers, but here we don't want to
|
|
// do anything if it's nil. That's mainly useful in tests where
|
|
// DisableAuthEntirely is set. Later, if we don't have the value set at all,
|
|
// we have some safeguards to ensure we fail safe (e.g. no output fields at
|
|
// all). So it provides a good indication as well whether we have set this
|
|
// where and when needed.
|
|
var reqInfo *requests.RequestContext
|
|
reqInfoRaw := ctx.Value(requests.ContextRequestInformationKey)
|
|
if reqInfoRaw != nil {
|
|
reqInfo = reqInfoRaw.(*requests.RequestContext)
|
|
}
|
|
|
|
// In tests we often simply disable auth so we can test the service handlers
|
|
// without fuss
|
|
if v.requestInfo.DisableAuthEntirely {
|
|
yes := true
|
|
ea.DisabledAuthEntirely = &yes
|
|
const op = "auth.(disabled).lookupScope"
|
|
ret.Scope.Id = v.requestInfo.ScopeIdOverride
|
|
if ret.Scope.Id == "" {
|
|
ret.Scope.Id = opts.withScopeId
|
|
}
|
|
// Look up scope details to return. We can skip a lookup when using the
|
|
// global scope
|
|
switch ret.Scope.Id {
|
|
case scope.Global.String():
|
|
ret.Scope = &scopes.ScopeInfo{
|
|
Id: scope.Global.String(),
|
|
Type: scope.Global.String(),
|
|
Name: scope.Global.String(),
|
|
Description: "Global Scope",
|
|
ParentScopeId: "",
|
|
}
|
|
|
|
default:
|
|
iamRepo, err := v.iamRepoFn()
|
|
if err != nil {
|
|
ret.Error = errors.Wrap(ctx, err, op, errors.WithMsg("failed to get iam repo"))
|
|
return
|
|
}
|
|
|
|
scp, err := iamRepo.LookupScope(v.ctx, ret.Scope.Id)
|
|
if err != nil {
|
|
ret.Error = errors.Wrap(ctx, err, op)
|
|
return
|
|
}
|
|
if scp == nil {
|
|
ret.Error = errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("non-existent scope %q", ret.Scope.Id))
|
|
return
|
|
}
|
|
ret.Scope = &scopes.ScopeInfo{
|
|
Id: scp.GetPublicId(),
|
|
Type: scp.GetType(),
|
|
Name: scp.GetName(),
|
|
Description: scp.GetDescription(),
|
|
ParentScopeId: scp.GetParentId(),
|
|
}
|
|
}
|
|
ret.UserId = v.requestInfo.UserIdOverride
|
|
ret.UserData.User.Id = util.Pointer(ret.UserId)
|
|
if reqInfo != nil {
|
|
reqInfo.UserId = ret.UserId
|
|
}
|
|
ea.UserInfo = &event.UserInfo{UserId: ret.UserId}
|
|
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(ctx)
|
|
}
|
|
|
|
var authResults perms.ACLResults
|
|
var grantTuples []perms.GrantTuple
|
|
var userData template.Data
|
|
var err error
|
|
authResults, ret.UserData, ret.Scope, v.acl, grantTuples, err = v.performAuthCheck(ctx)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error performing authn/authz check"))
|
|
return
|
|
}
|
|
|
|
if ret.UserData.User.Id != nil {
|
|
ret.UserId = *ret.UserData.User.Id
|
|
}
|
|
ret.AuthTokenId = v.requestInfo.PublicId
|
|
ret.AuthenticationFinished = authResults.AuthenticationFinished
|
|
if !authResults.Authorized {
|
|
if v.requestInfo.DisableAuthzFailures {
|
|
ret.Error = nil
|
|
// TODO: Decide whether to remove this
|
|
err := event.WriteObservation(ctx, op,
|
|
event.WithHeader(
|
|
"auth-results", struct {
|
|
Msg string `json:"msg"`
|
|
Resource *perms.Resource `json:"resource"`
|
|
UserId string `json:"user_id"`
|
|
Action string `json:"action"`
|
|
}{
|
|
Msg: "failed authz info for request",
|
|
Resource: v.res,
|
|
UserId: ret.UserId,
|
|
Action: v.act.String(),
|
|
}))
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err)
|
|
}
|
|
} 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 == globals.AnonymousUserId {
|
|
ret.Error = handlers.UnauthenticatedError()
|
|
}
|
|
ea.UserInfo = &event.UserInfo{
|
|
UserId: ret.UserId,
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
grants := make([]event.Grant, 0, len(grantTuples))
|
|
for _, g := range grantTuples {
|
|
grants = append(grants, event.Grant{
|
|
Grant: g.Grant,
|
|
RoleId: g.RoleId,
|
|
ScopeId: g.ScopeId,
|
|
})
|
|
}
|
|
ea.UserInfo = &event.UserInfo{
|
|
UserId: ret.UserId,
|
|
}
|
|
if userData.Account.Id != nil {
|
|
ea.UserInfo.AuthAccountId = *userData.Account.Id
|
|
}
|
|
ea.GrantsInfo = &event.GrantsInfo{
|
|
Grants: grants,
|
|
}
|
|
if userData.User.FullName != nil {
|
|
ea.UserName = *userData.User.FullName
|
|
}
|
|
if userData.User.Email != nil {
|
|
ea.UserEmail = *userData.User.Email
|
|
}
|
|
|
|
if reqInfo != nil {
|
|
reqInfo.UserId = ret.UserId
|
|
reqInfo.OutputFields = authResults.OutputFields
|
|
}
|
|
|
|
ret.Error = nil
|
|
return
|
|
}
|
|
|
|
func (v *verifier) decryptToken(ctx context.Context) {
|
|
const op = "auth.(verifier).decryptToken"
|
|
switch v.requestInfo.TokenFormat {
|
|
case uint32(AuthTokenTypeUnknown):
|
|
// Nothing to decrypt
|
|
return
|
|
|
|
case uint32(AuthTokenTypeBearer), uint32(AuthTokenTypeSplitCookie):
|
|
if v.kms == nil {
|
|
event.WriteError(ctx, op, stderrors.New("no KMS object available to authz system"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
tokenRepo, err := v.authTokenRepoFn()
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("failed to get authtoken repo"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
at, err := tokenRepo.LookupAuthToken(v.ctx, v.requestInfo.PublicId)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("failed to look up auth token by public ID"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
if at == nil {
|
|
event.WriteError(ctx, op, stderrors.New("nil result from looking up auth token by public ID"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
tokenWrapper, err := v.kms.GetWrapper(v.ctx, at.GetScopeId(), kms.KeyPurposeTokens)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("unable to get wrapper for tokens; continuing as anonymous user"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
version := v.requestInfo.EncryptedToken[0:len(globals.ServiceTokenV1)]
|
|
switch version {
|
|
case globals.ServiceTokenV1:
|
|
default:
|
|
event.WriteError(ctx, op, stderrors.New("unknown token encryption version; continuing as anonymous user"), event.WithInfo("version", version))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
marshaledToken, err := base58.FastBase58Decoding(v.requestInfo.EncryptedToken[len(globals.ServiceTokenV1):])
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error unmarshaling base58 token; continuing as anonymous user"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
blobInfo := new(wrapping.BlobInfo)
|
|
if err := proto.Unmarshal(marshaledToken, blobInfo); err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error decoding encrypted token; continuing as anonymous user"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
s1Bytes, err := tokenWrapper.Decrypt(v.ctx, blobInfo, wrapping.WithAad([]byte(v.requestInfo.PublicId)))
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error decrypting encrypted token; continuing as anonymous user"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
var s1Info tokens.S1TokenInfo
|
|
if err := proto.Unmarshal(s1Bytes, &s1Info); err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error unmarshaling token info; continuing as anonymous user"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
if v.requestInfo.TokenFormat == uint32(AuthTokenTypeUnknown) || s1Info.Token == "" || v.requestInfo.PublicId == "" {
|
|
event.WriteError(ctx, op, stderrors.New("after parsing, could not find valid token; continuing as anonymous user"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
|
|
v.requestInfo.Token = s1Info.Token
|
|
return
|
|
|
|
case uint32(AuthTokenTypeRecoveryKms):
|
|
if v.kms == nil {
|
|
event.WriteError(ctx, op, stderrors.New("no KMS object available to authz system"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
wrapper := v.kms.GetExternalWrappers(ctx).Recovery()
|
|
if wrapper == nil {
|
|
event.WriteError(ctx, op, stderrors.New("no recovery KMS is available"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
info, err := recovery.ParseRecoveryToken(v.ctx, wrapper, v.requestInfo.EncryptedToken)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("decrypt recovery token: error parsing and validating recovery token"))
|
|
v.requestInfo.TokenFormat = uint32(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()) {
|
|
event.WriteError(ctx, op, stderrors.New("recovery token has expired (possible replay attack)"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
repo, err := v.serversRepoFn()
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("decrypt recovery token: error fetching server repo"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
if err := repo.AddNonce(v.ctx, info.Nonce, server.NoncePurposeRecovery); err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("decrypt recovery token: error adding nonce to database (possible replay attack)"))
|
|
v.requestInfo.TokenFormat = uint32(AuthTokenTypeUnknown)
|
|
return
|
|
}
|
|
event.WriteError(ctx, op, stderrors.New("recovery KMS was used to authorize a call"), event.WithInfo("url", v.requestInfo.Path, "method", v.requestInfo.Method))
|
|
}
|
|
}
|
|
|
|
func (v verifier) performAuthCheck(ctx context.Context) (
|
|
aclResults perms.ACLResults,
|
|
userData template.Data,
|
|
scopeInfo *scopes.ScopeInfo,
|
|
retAcl perms.ACL,
|
|
grantTuples []perms.GrantTuple,
|
|
retErr error,
|
|
) {
|
|
const op = "auth.(verifier).performAuthCheck"
|
|
// Ensure we return an error by default if we forget to set this somewhere
|
|
retErr = errors.New(ctx, errors.Unknown, op, "default auth error", errors.WithoutEvent())
|
|
// Make the linter happy
|
|
_ = retErr
|
|
scopeInfo = new(scopes.ScopeInfo)
|
|
|
|
// This will always be set, so further down below we can switch on whether
|
|
// it's empty
|
|
userData.User.Id = util.Pointer(globals.AnonymousUserId)
|
|
|
|
// Validate the token and fetch the corresponding user ID
|
|
switch v.requestInfo.TokenFormat {
|
|
case uint32(AuthTokenTypeUnknown):
|
|
// Nothing; remain as the anonymous user
|
|
|
|
case uint32(AuthTokenTypeRecoveryKms):
|
|
// We validated the encrypted token in decryptToken and handled the
|
|
// nonces there, so just set the user
|
|
userData.User.Id = util.Pointer(globals.RecoveryUserId)
|
|
|
|
case uint32(AuthTokenTypeBearer), uint32(AuthTokenTypeSplitCookie):
|
|
if v.requestInfo.Token == "" {
|
|
// This will end up staying as the anonymous user
|
|
break
|
|
}
|
|
tokenRepo, err := v.authTokenRepoFn()
|
|
if err != nil {
|
|
retErr = errors.Wrap(ctx, err, op)
|
|
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
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error validating token; continuing as anonymous user"))
|
|
break
|
|
}
|
|
if at != nil {
|
|
userData.Account.Id = util.Pointer(at.GetAuthAccountId())
|
|
userData.User.Id = util.Pointer(at.GetIamUserId())
|
|
if *userData.User.Id == "" {
|
|
event.WriteError(ctx, op, stderrors.New("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"), event.WithInfo("token_id", at.GetPublicId()))
|
|
userData.User.Id = util.Pointer(globals.AnonymousUserId)
|
|
userData.Account.Id = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
iamRepo, err := v.iamRepoFn()
|
|
if err != nil {
|
|
retErr = errors.Wrap(ctx, err, op, errors.WithMsg("failed to get iam repo"))
|
|
return
|
|
}
|
|
|
|
u, _, err := iamRepo.LookupUser(ctx, *userData.User.Id)
|
|
if err != nil {
|
|
retErr = errors.Wrap(ctx, err, op, errors.WithMsg("failed to lookup user"))
|
|
return
|
|
}
|
|
userData.User.Name = util.Pointer(u.Name)
|
|
userData.User.Email = util.Pointer(u.Email)
|
|
userData.User.FullName = util.Pointer(u.FullName)
|
|
|
|
if userData.Account.Id != nil && *userData.Account.Id != "" && v.passwordAuthRepoFn != nil && v.oidcAuthRepoFn != nil && v.ldapAuthRepoFn != nil {
|
|
const domain = "auth"
|
|
var acct auth.Account
|
|
var err error
|
|
switch subtypes.SubtypeFromId(domain, *userData.Account.Id) {
|
|
case password.Subtype:
|
|
repo, repoErr := v.passwordAuthRepoFn()
|
|
if repoErr != nil {
|
|
retErr = errors.Wrap(ctx, repoErr, op, errors.WithMsg("failed to get password auth repo"))
|
|
return
|
|
}
|
|
acct, err = repo.LookupAccount(ctx, *userData.Account.Id)
|
|
case oidc.Subtype:
|
|
repo, repoErr := v.oidcAuthRepoFn()
|
|
if repoErr != nil {
|
|
retErr = errors.Wrap(ctx, repoErr, op, errors.WithMsg("failed to get oidc auth repo"))
|
|
return
|
|
}
|
|
acct, err = repo.LookupAccount(ctx, *userData.Account.Id)
|
|
case ldap.Subtype:
|
|
repo, repoErr := v.ldapAuthRepoFn()
|
|
if repoErr != nil {
|
|
retErr = errors.Wrap(ctx, repoErr, op, errors.WithMsg("failed to get ldap auth repo"))
|
|
return
|
|
}
|
|
acct, err = repo.LookupAccount(ctx, *userData.Account.Id)
|
|
default:
|
|
retErr = errors.Wrap(ctx, err, op, errors.WithMsg("unrecognized account id type"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
if errors.IsNotFoundError(err) {
|
|
retErr = errors.Wrap(ctx, err, op, errors.WithMsg("account doesn't exist"))
|
|
return
|
|
}
|
|
retErr = errors.Wrap(ctx, err, op, errors.WithMsg("error looking up account"))
|
|
return
|
|
}
|
|
userData.Account.Name = util.Pointer(acct.GetName())
|
|
userData.Account.Email = util.Pointer(acct.GetEmail())
|
|
userData.Account.LoginName = util.Pointer(acct.GetLoginName())
|
|
userData.Account.Subject = util.Pointer(acct.GetSubject())
|
|
}
|
|
|
|
// Look up scope details to return. We can skip a lookup when using the
|
|
// global scope
|
|
switch v.res.ScopeId {
|
|
case scope.Global.String():
|
|
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 = errors.Wrap(ctx, err, op)
|
|
return
|
|
}
|
|
if scp == nil {
|
|
retErr = errors.New(ctx, errors.InvalidParameter, op, fmt.Sprint("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 == uint32(AuthTokenTypeRecoveryKms) {
|
|
aclResults.AuthenticationFinished = true
|
|
aclResults.Authorized = true
|
|
retErr = nil
|
|
return
|
|
}
|
|
|
|
var parsedGrants []perms.Grant
|
|
|
|
// Fetch and parse grants for this user ID (which may include grants for
|
|
// u_anon and u_auth)
|
|
grantTuples, err = iamRepo.GrantsForUser(v.ctx, *userData.User.Id)
|
|
if err != nil {
|
|
retErr = errors.Wrap(ctx, err, op)
|
|
return
|
|
}
|
|
parsedGrants = make([]perms.Grant, 0, len(grantTuples))
|
|
// Note: Below, we always skip validation so that we don't error on formats
|
|
// that we've since restricted, e.g. "id=foo;actions=create,read". These
|
|
// will simply not have an effect.
|
|
for _, pair := range grantTuples {
|
|
permsOpts := []perms.Option{
|
|
perms.WithUserId(*userData.User.Id),
|
|
perms.WithSkipFinalValidation(true),
|
|
}
|
|
if userData.Account.Id != nil {
|
|
permsOpts = append(permsOpts, perms.WithAccountId(*userData.Account.Id))
|
|
}
|
|
parsed, err := perms.Parse(
|
|
pair.ScopeId,
|
|
pair.Grant,
|
|
permsOpts...)
|
|
if err != nil {
|
|
retErr = errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed to parse grant %#v", pair.Grant)))
|
|
return
|
|
}
|
|
parsedGrants = append(parsedGrants, parsed)
|
|
}
|
|
|
|
retAcl = perms.NewACL(parsedGrants...)
|
|
aclResults = retAcl.Allowed(*v.res, v.act, *userData.User.Id)
|
|
// We don't set authenticated above because setting this but not authorized
|
|
// is used for further permissions checks, such as during recursive listing.
|
|
// So we want to make sure any code relying on that has the full set of
|
|
// grants successfully loaded.
|
|
aclResults.AuthenticationFinished = true
|
|
retErr = nil
|
|
return
|
|
}
|
|
|
|
// FetchActionSetForId returns the allowed actions for a given ID using the
|
|
// current set of ACLs and all other parameters the same (user, etc.)
|
|
func (r *VerifyResults) FetchActionSetForId(ctx context.Context, id string, availableActions action.ActionSet, opt ...Option) action.ActionSet {
|
|
return r.fetchActions(id, resource.Unknown, availableActions, opt...)
|
|
}
|
|
|
|
// FetchActionSetForType returns the allowed actions for a given collection type
|
|
// using the current set of ACLs and all other parameters the same (user, etc.)
|
|
func (r *VerifyResults) FetchActionSetForType(ctx context.Context, typ resource.Type, availableActions action.ActionSet, opt ...Option) action.ActionSet {
|
|
return r.fetchActions("", typ, availableActions, opt...)
|
|
}
|
|
|
|
func (r *VerifyResults) fetchActions(id string, typ resource.Type, availableActions action.ActionSet, opt ...Option) action.ActionSet {
|
|
switch {
|
|
case r.v.requestInfo.DisableAuthEntirely,
|
|
r.v.requestInfo.TokenFormat == uint32(AuthTokenTypeRecoveryKms):
|
|
// TODO: See if we can be better about what we return with the anonymous
|
|
// user and recovery KMS, perhaps given exclusionary options for each
|
|
return availableActions
|
|
}
|
|
|
|
// If there is no user ID set by definition there are no actions to fetch.
|
|
// This shouldn't happen because we should always fall back to at least the
|
|
// anonymous user so it's defense in depth.
|
|
if r.UserData.User.Id == nil {
|
|
return nil
|
|
}
|
|
|
|
opts := getOpts(opt...)
|
|
res := opts.withResource
|
|
// If not passed in, use what's already been populated through verification
|
|
if res == nil {
|
|
res = r.v.res
|
|
}
|
|
// If this is being called directly we may not have a resource yet
|
|
if res == nil {
|
|
res = new(perms.Resource)
|
|
}
|
|
if id != "" {
|
|
res.Id = id
|
|
}
|
|
if typ != resource.Unknown {
|
|
res.Type = typ
|
|
}
|
|
|
|
ret := make(action.ActionSet, 0, len(availableActions))
|
|
for _, act := range availableActions {
|
|
if r.v.acl.Allowed(*res, act, *r.UserData.User.Id).Authorized {
|
|
ret = append(ret, act)
|
|
}
|
|
}
|
|
if len(ret) == 0 {
|
|
return nil
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func (r *VerifyResults) FetchOutputFields(res perms.Resource, act action.Type) *perms.OutputFields {
|
|
var ret *perms.OutputFields
|
|
switch {
|
|
case r.v.requestInfo.TokenFormat == uint32(AuthTokenTypeRecoveryKms):
|
|
return ret.AddFields([]string{"*"})
|
|
case r.v.requestInfo.DisableAuthEntirely:
|
|
return ret
|
|
case r.UserData.User.Id == nil:
|
|
// If there is no user ID set by definition there are no actions to fetch.
|
|
// This shouldn't happen because we should always fall back to at least the
|
|
// anonymous user so it's defense in depth.
|
|
return ret
|
|
}
|
|
|
|
return r.v.acl.Allowed(res, act, *r.UserData.User.Id).OutputFields
|
|
}
|
|
|
|
// ACL returns the perms.ACL of the verifier.
|
|
func (r *VerifyResults) ACL() perms.ACL {
|
|
if r.v == nil {
|
|
return perms.ACL{}
|
|
}
|
|
|
|
return r.v.acl
|
|
}
|
|
|
|
// 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(ctx context.Context, kmsCache *kms.Kms, req *http.Request) (string, string, uint32) {
|
|
const op = "auth.GetTokenFromRequest"
|
|
// 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 anonymous user
|
|
return "", "", uint32(AuthTokenTypeUnknown)
|
|
}
|
|
|
|
if strings.HasPrefix(fullToken, "r_") {
|
|
return "", fullToken, uint32(AuthTokenTypeRecoveryKms)
|
|
}
|
|
|
|
splitFullToken := strings.Split(fullToken, "_")
|
|
if len(splitFullToken) != 3 {
|
|
event.WriteError(ctx, op, stderrors.New("unexpected number of segments in token; continuing as anonymous user"), event.WithInfo("expected", 3, "found", len(splitFullToken)))
|
|
return "", "", uint32(AuthTokenTypeUnknown)
|
|
}
|
|
|
|
publicId := strings.Join(splitFullToken[0:2], "_")
|
|
encryptedToken := splitFullToken[2]
|
|
|
|
return publicId, encryptedToken, uint32(receivedTokenType)
|
|
}
|
|
|
|
// ScopesAuthorizedForList retrieves and returns all scopes where a user is authorized
|
|
// to perform a *list* action on. It looks recursively from `rootScopeId`.
|
|
func (r *VerifyResults) ScopesAuthorizedForList(ctx context.Context, rootScopeId string, resourceType resource.Type) (map[string]*scopes.ScopeInfo, error) {
|
|
const op = "auth.(VerifyResults).ScopesAuthorizedForList"
|
|
|
|
// Validation
|
|
switch {
|
|
case resourceType == resource.Unknown:
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "unknown resource")
|
|
case r.v.iamRepoFn == nil:
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "nil iam repo")
|
|
case rootScopeId == "":
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing root scope id")
|
|
case r.Scope == nil:
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "nil scope in auth results")
|
|
}
|
|
|
|
repo, err := r.v.iamRepoFn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get all scopes recursively. Start at global because we need to take into
|
|
// account permissions in parent scopes even if they want to scale back the
|
|
// returned information to a child scope and its children.
|
|
scps, err := repo.ListScopesRecursively(ctx, scope.Global.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var deferredScopes []*iam.Scope
|
|
var globalHasList bool // Store whether global has list permission
|
|
scopeResourceMap := make(map[string]*scopes.ScopeInfo) // Scope data per scope id
|
|
|
|
// For each scope, see if we have permission to list that resource in that scope
|
|
for _, scp := range scps {
|
|
scpId := scp.GetPublicId()
|
|
aSet := r.FetchActionSetForType(ctx,
|
|
resource.Unknown, // This is overridden by `WithResource` option.
|
|
action.ActionSet{action.List},
|
|
WithResource(&perms.Resource{Type: resourceType, ScopeId: scpId}),
|
|
)
|
|
|
|
// We only expect the action set to be nothing, or list. In case
|
|
// this is not the case, we bail out.
|
|
switch {
|
|
case len(aSet) == 0:
|
|
// Defer until we've read all scopes. We do this because if the
|
|
// ordering coming back isn't in parent-first ordering our map
|
|
// lookup might fail.
|
|
deferredScopes = append(deferredScopes, scp)
|
|
case len(aSet) == 1 || r.UserId == globals.RecoveryUserId:
|
|
if aSet[0] != action.List {
|
|
return nil, errors.New(ctx, errors.Internal, op, "unexpected action in set")
|
|
}
|
|
if scopeResourceMap[scpId] == nil {
|
|
scopeResourceMap[scpId] = &scopes.ScopeInfo{
|
|
Id: scp.GetPublicId(),
|
|
Type: scp.GetType(),
|
|
Name: scp.GetName(),
|
|
Description: scp.GetDescription(),
|
|
ParentScopeId: scp.GetParentId(),
|
|
}
|
|
}
|
|
if scpId == scope.Global.String() {
|
|
globalHasList = true
|
|
}
|
|
default:
|
|
return nil, errors.New(ctx, errors.Internal, op, "unexpected number of actions back in set")
|
|
}
|
|
}
|
|
|
|
// Now go through the deferred scopes and see if a parent matches
|
|
for _, scp := range deferredScopes {
|
|
// If they had list on global scope anything else is automatically
|
|
// included; otherwise if they had list on the parent scope, this
|
|
// scope is included in the map and is sufficient here.
|
|
if globalHasList || scopeResourceMap[scp.GetParentId()] != nil {
|
|
scpId := scp.GetPublicId()
|
|
if scopeResourceMap[scpId] == nil {
|
|
scopeResourceMap[scpId] = &scopes.ScopeInfo{
|
|
Id: scp.GetPublicId(),
|
|
Type: scp.GetType(),
|
|
Name: scp.GetName(),
|
|
Description: scp.GetDescription(),
|
|
ParentScopeId: scp.GetParentId(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Elide out any that aren't under the root scope id
|
|
elideScopes := make([]string, 0, len(scopeResourceMap))
|
|
for scpId, scp := range scopeResourceMap {
|
|
switch rootScopeId {
|
|
// If the root is global, it matches
|
|
case scope.Global.String():
|
|
// If the current scope matches the root, it matches
|
|
case scpId:
|
|
// Or if the parent of this scope is the root (for orgs that would mean
|
|
// a root scope ID which is covered in the case above, so this is really
|
|
// projects matching an org used as the root)
|
|
case scp.ParentScopeId:
|
|
default:
|
|
elideScopes = append(elideScopes, scpId)
|
|
}
|
|
}
|
|
for _, scpId := range elideScopes {
|
|
delete(scopeResourceMap, scpId)
|
|
}
|
|
|
|
// If we have nothing in scopeInfoMap at this point, we aren't authorized
|
|
// anywhere so return 403.
|
|
if len(scopeResourceMap) == 0 {
|
|
return nil, handlers.ForbiddenError()
|
|
}
|
|
|
|
return scopeResourceMap, nil
|
|
}
|