Skip to content
Snippets Groups Projects
Select Git revision
  • 00724aff2bcbdb66f2dfb2dd4be581fec9bc5d98
  • master default protected
  • renovate/golang-1.x
  • renovate/go-1.x
  • renovate/github.com-mattn-go-sqlite3-1.x
  • renovate/github.com-prometheus-client_golang-1.x
  • renovate/git.autistici.org-ai3-go-common-digest
  • better-compression
8 results

manager_test.go

Blame
  • service.go 9.69 KiB
    package accountserver
    
    import (
    	"context"
    	"errors"
    	"fmt"
    	"log"
    	"time"
    
    	ct "git.autistici.org/ai3/go-common/ldap/compositetypes"
    	"git.autistici.org/id/auth"
    	authclient "git.autistici.org/id/auth/client"
    	"git.autistici.org/id/go-sso"
    	umdb "git.autistici.org/id/usermetadb"
    	umdbc "git.autistici.org/id/usermetadb/client"
    )
    
    // Backend user database interface.
    //
    // We are using a transactional interface even if the actual backend
    // (LDAP) does not support atomic transactions, just so it is easy to
    // add more backends in the future (like SQL).
    type Backend interface {
    	NewTransaction() (TX, error)
    }
    
    // TX represents a single transaction with the backend and offers a
    // high-level data management abstraction.
    //
    // All methods share similar semantics: Get methods will return nil if
    // the requested object is not found, and only return an error in case
    // of trouble reaching the backend itself.
    //
    // The backend enforces strict public/private data separation by
    // having Get methods return public objects (as defined in types.go),
    // and using specialized methods to modify the private
    // (authentication-related) attributes.
    //
    // The API passes around the full User object, where a simple username
    // would usually suffice, because it needs to synchronize things
    // between resources: this is primarily due to the coupling between
    // account and email resource.
    //
    // We might add more sophisticated resource query methods later, as
    // admin-level functionality.
    //
    type TX interface {
    	Commit(context.Context) error
    
    	GetResource(context.Context, ResourceID) (*RawResource, error)
    	UpdateResource(context.Context, *Resource) error
    	CreateResources(context.Context, *User, []*Resource) ([]*Resource, error)
    	SetResourcePassword(context.Context, *Resource, string) error
    	FindResource(context.Context, FindResourceRequest) (*RawResource, error)
    	HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
    
    	GetUser(context.Context, string) (*RawUser, error)
    	UpdateUser(context.Context, *User) error
    	CreateUser(context.Context, *User) (*User, error)
    	SetUserPassword(context.Context, *User, string) error
    	SetAccountRecoveryHint(context.Context, *User, string, string) error
    	DeleteAccountRecoveryHint(context.Context, *User) error
    	SetUserEncryptionKeys(context.Context, *User, []*ct.EncryptedKey) error
    	SetUserEncryptionPublicKey(context.Context, *User, []byte) error
    	SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
    	DeleteApplicationSpecificPassword(context.Context, *User, string) error
    	SetUserTOTPSecret(context.Context, *User, string) error
    	DeleteUserTOTPSecret(context.Context, *User) error
    
    	// Lightweight user search (backend-specific pattern).
    	// Returns list of matching usernames.
    	SearchUser(context.Context, string) ([]string, error)
    
    	// Resource search (backend-specific pattern).
    	SearchResource(context.Context, string) ([]*RawResource, error)
    
    	// Resource ACL check (does not necessarily hit the database).
    	CanAccessResource(context.Context, string, *Resource) bool
    
    	// Return the next (or any, really) available user ID.
    	NextUID(context.Context) (int, error)
    }
    
    // FindResourceRequest contains parameters for searching a resource by name.
    type FindResourceRequest struct {
    	Type string
    	Name string
    }
    
    // AccountService implements the business logic and high-level functionality
    // of the user accounts management service. It provides common services that
    // the various action handlers can use, such as validation, auditing, etc.
    //
    // Action handlers aren't implemented as methods on AccountService,
    // instead they get access to this object via the RequestContext.
    type AccountService struct {
    	*authService
    
    	backend Backend
    	audit   auditLogger
    	umdb    umdbc.Client
    
    	authClient                 authclient.Client
    	userAuthService            string
    	accountRecoveryAuthService string
    
    	validationCtx     *validationContext
    	fieldValidators   *fieldValidators
    	resourceValidator *resourceValidator
    	userValidator     UserValidatorFunc
    	resourceTemplates *templateContext
    
    	enableOpportunisticEncryption bool
    }
    
    // NewAccountService builds a new AccountService with the specified configuration.
    func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
    	ssoValidator, err := config.ssoValidator()
    	if err != nil {
    		return nil, err
    	}
    
    	var authSocket = authclient.DefaultSocketPath
    	if config.AuthSocket != "" {
    		authSocket = config.AuthSocket
    	}
    	authClient := authclient.New(authSocket)
    
    	return newAccountServiceInternal(backend, config, ssoValidator, authClient)
    }
    
    const (
    	defaultUserAuthService            = "accountserver"
    	defaultAccountRecoveryAuthService = "accountserver-recovery"
    )
    
    func defaultStr(value, dflt string) string {
    	if value == "" {
    		return dflt
    	}
    	return value
    }
    
    // This function is split out from the above so we can pass stubs for
    // some backends when testing.
    func newAccountServiceInternal(backend Backend, config *Config, ssoValidator sso.Validator, authClient authclient.Client) (*AccountService, error) {
    	if err := config.compile(); err != nil {
    		return nil, fmt.Errorf("configuration error: %v", err)
    	}
    
    	s := &AccountService{
    		authService:                   newAuthService(config, ssoValidator),
    		backend:                       backend,
    		authClient:                    authClient,
    		userAuthService:               defaultStr(config.UserAuthService, defaultUserAuthService),
    		accountRecoveryAuthService:    defaultStr(config.AccountRecoveryAuthService, defaultAccountRecoveryAuthService),
    		enableOpportunisticEncryption: config.EnableOpportunisticEncryption,
    	}
    	if config.AuditLogsToSyslog {
    		s.audit = newSyslogAuditLogger()
    	} else {
    		s.audit = newStderrAuditLogger()
    	}
    
    	if config.UserMetaDB != nil {
    		var err error
    		s.umdb, err = umdbc.New(config.UserMetaDB)
    		if err != nil {
    			return nil, err
    		}
    	}
    
    	vc, err := config.validationContext(backend)
    	if err != nil {
    		return nil, err
    	}
    	s.validationCtx = vc
    	s.fieldValidators = newFieldValidators(vc)
    	s.resourceValidator = newResourceValidator(vc)
    	s.userValidator = vc.validUser()
    
    	s.resourceTemplates = config.templateContext()
    
    	return s, nil
    }
    
    // The authService parses identity data from a request (usually in the
    // form of a SSO token), validates it, and creates an Auth object to
    // go along the RequestContext.
    type authService struct {
    	validator     sso.Validator
    	ssoService    string
    	ssoGroups     []string
    	ssoAdminGroup string
    }
    
    func newAuthService(config *Config, v sso.Validator) *authService {
    	return &authService{
    		validator:     v,
    		ssoService:    config.SSO.Service,
    		ssoGroups:     config.SSO.Groups,
    		ssoAdminGroup: config.SSO.AdminGroup,
    	}
    }
    
    func (s *authService) authFromSSO(ssoToken string) (Auth, error) {
    	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
    	if err != nil {
    		return Auth{}, err
    	}
    
    	a := Auth{Username: tkt.User}
    
    	for _, g := range tkt.Groups {
    		if g == s.ssoAdminGroup {
    			a.IsAdmin = true
    			break
    		}
    	}
    
    	return a, nil
    }
    
    // Fetch a user from the backend and return an error if not found
    // (instead of returning a nil User). Also, set dynamic attributes
    // like resource Groups.
    func getUserOrDie(ctx context.Context, tx TX, username string) (*RawUser, error) {
    	user, err := tx.GetUser(ctx, username)
    	if err != nil {
    		return nil, newBackendError(err)
    	}
    	if user == nil {
    		return nil, ErrUserNotFound
    	}
    
    	user.groupWebResources()
    
    	return user, nil
    }
    
    func getResourceOrDie(ctx context.Context, tx TX, id ResourceID) (*RawResource, error) {
    	r, err := tx.GetResource(ctx, id)
    	if err != nil {
    		return nil, newBackendError(err)
    	}
    	if r == nil {
    		return nil, ErrResourceNotFound
    	}
    	return r, nil
    }
    
    var umdbLogTimeout = 30 * time.Second
    
    func (s *AccountService) logUserAction(user *User, logtype, logmsg string) {
    	if s.umdb == nil {
    		return
    	}
    	go func() {
    		ctx, cancel := context.WithTimeout(context.Background(), umdbLogTimeout)
    		defer cancel()
    		if err := s.umdb.AddLog(ctx, "", &umdb.LogEntry{
    			Timestamp: time.Now(),
    			Username:  user.Name,
    			Type:      logtype,
    			Message:   logmsg,
    			Service:   "accountserver",
    		}); err != nil {
    			log.Printf("usermetadb.AddLog error for %s: %v", user.Name, err)
    		}
    	}()
    }
    
    // Handle the given Request. Returns its result.
    func (s *AccountService) Handle(ctx context.Context, r Request) (interface{}, error) {
    	tx, err := s.backend.NewTransaction()
    	if err != nil {
    		return nil, err
    	}
    	rctx := &RequestContext{
    		AccountService: s,
    		Context:        ctx,
    		TX:             tx,
    	}
    
    	// Populate the context.
    	if err = r.PopulateContext(rctx); err != nil {
    		return nil, err
    	}
    
    	// Validate the request.
    	if err = r.Validate(rctx); err != nil {
    		return nil, newRequestError(err)
    	}
    
    	// Authorize the request.
    	if err = r.Authorize(rctx); err != nil {
    		return nil, newAuthError(err)
    	}
    
    	// Perform the actions.
    	resp, err := r.Serve(rctx)
    	if err != nil {
    		return nil, err
    	}
    
    	if err := tx.Commit(ctx); err != nil {
    		return nil, err
    	}
    
    	return resp, nil
    }
    
    func (s *AccountService) callAuthServer(ctx context.Context, service, username, password, remoteAddr string) error {
    	resp, err := s.authClient.Authenticate(ctx, &auth.Request{
    		Service:  service,
    		Username: username,
    		Password: []byte(password),
    		DeviceInfo: &auth.DeviceInfo{
    			RemoteAddr: remoteAddr,
    		},
    	})
    	if err != nil {
    		return err
    	}
    	if resp.Status != auth.StatusOK {
    		return errors.New("authentication failure")
    	}
    	return nil
    }
    
    func (s *AccountService) authorizeUser(ctx context.Context, username, password, remoteAddr string) error {
    	return s.callAuthServer(ctx, s.userAuthService, username, password, remoteAddr)
    }
    
    func (s *AccountService) authorizeAccountRecovery(ctx context.Context, username, password, remoteAddr string) error {
    	return s.callAuthServer(ctx, s.accountRecoveryAuthService, username, password, remoteAddr)
    }