Skip to content
Snippets Groups Projects
Select Git revision
  • 06bdd691508aa3f57745069869b97b7ff3768e52
  • master default protected
  • renovate/github.com-prometheus-client_golang-1.x
3 results

main.go

Blame
  • Forked from ai3 / tools / hark2
    Source project has a limited visibility.
    actions.go 18.57 KiB
    package accountserver
    
    import (
    	"context"
    	"crypto/rand"
    	"encoding/base64"
    	"errors"
    
    	"git.autistici.org/ai3/go-common/pwhash"
    	"git.autistici.org/id/go-sso"
    	"git.autistici.org/id/keystore/userenckey"
    	"github.com/pquerna/otp/totp"
    )
    
    // 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.
    //
    // We might add more sophisticated resource query methods later, as
    // admin-level functionality.
    //
    type TX interface {
    	Commit(context.Context) error
    
    	GetResource(context.Context, ResourceID) (*Resource, error)
    	UpdateResource(context.Context, *Resource) error
    	SetResourcePassword(context.Context, *Resource, string) error
    
    	GetUser(context.Context, string) (*User, error)
    	SetUserPassword(context.Context, *User, string) error
    	GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
    	SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) 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
    
    	HasAnyResource(context.Context, []FindResourceRequest) (bool, 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.
    type AccountService struct {
    	validator     sso.Validator
    	ssoService    string
    	ssoGroups     []string
    	ssoAdminGroup string
    
    	passwordValidator   ValidatorFunc
    	dataValidators      map[string]ValidatorFunc
    	adminDataValidators map[string]ValidatorFunc
    }
    
    func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
    	ssoValidator, err := config.ssoValidator()
    	if err != nil {
    		return nil, err
    	}
    
    	return newAccountServiceWithSSO(backend, config, ssoValidator), nil
    }
    
    func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService {
    	s := &AccountService{
    		validator:     ssoValidator,
    		ssoService:    config.SSO.Service,
    		ssoGroups:     config.SSO.Groups,
    		ssoAdminGroup: config.SSO.AdminGroup,
    	}
    
    	validationConfig := config.validationConfig()
    	domainBackend := config.domainBackend()
    	s.dataValidators = map[string]ValidatorFunc{
    		ResourceTypeEmail:       validHostedEmail(validationConfig, domainBackend, backend),
    		ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend),
    	}
    	s.passwordValidator = validPassword(validationConfig)
    
    	return s
    }
    
    func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
    	for _, g := range tkt.Groups {
    		if g == s.ssoAdminGroup {
    			return true
    		}
    	}
    	return false
    }
    
    var (
    	ErrUnauthorized     = errors.New("unauthorized")
    	ErrUserNotFound     = errors.New("user not found")
    	ErrResourceNotFound = errors.New("resource not found")
    )
    
    func (s *AccountService) authorizeAdmin(ctx context.Context, tx TX, username, ssoToken string) (*User, error) {
    	// Validate the SSO ticket.
    	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
    	if err != nil {
    		return nil, newAuthError(err)
    	}
    
    	// Requests are allowed if the SSO ticket corresponds to an admin, or if
    	// it identifies the same user that we're querying.
    	if !s.isAdmin(tkt) {
    		return nil, newAuthError(ErrUnauthorized)
    	}
    
    	user, err := tx.GetUser(ctx, username)
    	if err != nil {
    		return nil, newBackendError(err)
    	}
    	if user == nil {
    		return nil, ErrUserNotFound
    	}
    	return user, nil
    }
    
    func (s *AccountService) authorizeUser(ctx context.Context, tx TX, username, ssoToken string) (*User, error) {
    	// First, check that the username matches the SSO ticket
    	// username (or that the SSO ticket has admin permissions).
    	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
    	if err != nil {
    		return nil, newAuthError(err)
    	}
    
    	// Requests are allowed if the SSO ticket corresponds to an admin, or if
    	// it identifies the same user that we're querying.
    	if !s.isAdmin(tkt) && tkt.User != username {
    		return nil, newAuthError(ErrUnauthorized)
    	}
    
    	user, err := tx.GetUser(ctx, username)
    	if err != nil {
    		return nil, newBackendError(err)
    	}
    	if user == nil {
    		return nil, ErrUserNotFound
    	}
    	return user, nil
    }
    
    // Extended version of authorizeUser that also directly checks the
    // user password. Used for account-privileged operations related to
    // credential manipulation.
    func (s *AccountService) authorizeUserWithPassword(ctx context.Context, tx TX, username, ssoToken, password string) (*User, error) {
    	// TODO: call out to the auth-server?
    	return s.authorizeUser(ctx, tx, username, ssoToken)
    }
    
    // RequestBase contains parameters shared by all request types.
    type RequestBase struct {
    	Username string `json:"username"`
    	SSO      string `json:"sso"`
    }
    
    // PrivilegedRequestBase extends RequestBase with the user password,
    // for privileged endpoints.
    type PrivilegedRequestBase struct {
    	RequestBase
    	CurPassword string `json:"cur_password"`
    }
    
    type GetUserRequest struct {
    	RequestBase
    }
    
    // GetUser returns public information about a user.
    func (s *AccountService) GetUser(ctx context.Context, tx TX, req *GetUserRequest) (*User, error) {
    	return s.authorizeUser(ctx, tx, req.Username, req.SSO)
    }
    
    // setResourceStatus sets the status of a single resource (shared
    // logic between enable / disable resource methods).
    func (s *AccountService) setResourceStatus(ctx context.Context, tx TX, username string, resourceID ResourceID, status string) error {
    	r, err := tx.GetResource(ctx, resourceID)
    	if err != nil {
    		return newBackendError(err)
    	}
    	if r == nil {
    		return ErrResourceNotFound
    	}
    	r.Status = status
    	if err := tx.UpdateResource(ctx, r); err != nil {
    		return newBackendError(err)
    	}
    	return nil
    }
    
    type DisableResourceRequest struct {
    	RequestBase
    	ResourceID ResourceID `json:"resource_id"`
    }
    
    // DisableResource disables a resource belonging to the user.
    func (s *AccountService) DisableResource(ctx context.Context, tx TX, req *DisableResourceRequest) error {
    	if _, err := s.authorizeUser(ctx, tx, req.Username, req.SSO); err != nil {
    		return err
    	}
    	return s.setResourceStatus(ctx, tx, req.Username, req.ResourceID, ResourceStatusInactive)
    }
    
    type EnableResourceRequest struct {
    	RequestBase
    	ResourceID ResourceID `json:"resource_id"`
    }
    
    // EnableResource enables a resource belonging to the user.
    func (s *AccountService) EnableResource(ctx context.Context, tx TX, req *EnableResourceRequest) error {
    	if _, err := s.authorizeUser(ctx, tx, req.Username, req.SSO); err != nil {
    		return err
    	}
    	return s.setResourceStatus(ctx, tx, req.Username, req.ResourceID, ResourceStatusActive)
    }
    
    type ChangeUserPasswordRequest struct {
    	PrivilegedRequestBase
    	Password string `json:"password"`
    }
    
    func (r *ChangeUserPasswordRequest) Validate(ctx context.Context, s *AccountService) error {
    	return s.passwordValidator(ctx, r.Password)
    }
    
    // ChangeUserPassword updates a user's password. It will also take
    // care of re-encrypting the user encryption key, if present.
    func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *ChangeUserPasswordRequest) error {
    	user, err := s.authorizeUserWithPassword(ctx, tx, req.Username, req.SSO, req.CurPassword)
    	if err != nil {
    		return err
    	}
    
    	if err = req.Validate(ctx, s); err != nil {
    		return newRequestError(err)
    	}
    
    	// If the user does not yet have an encryption key, generate one now.
    	if !user.HasEncryptionKeys {
    		err = s.initializeUserEncryptionKeys(ctx, tx, user, req.CurPassword)
    	} else {
    		err = s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID)
    	}
    	if err != nil {
    		return err
    	}
    
    	// Set the encrypted password attribute on the user (will set it on emails too).
    	encPass := pwhash.Encrypt(req.Password)
    	if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
    		return newBackendError(err)
    	}
    
    	return nil
    }
    
    // Initialize the user encryption key list, by creating a new "main" key
    // encrypted with the given password (which must be the primary password for the
    // user).
    func (s *AccountService) initializeUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword string) error {
    	// Generate a new key pair.
    	pub, priv, err := userenckey.GenerateKey()
    	if err != nil {
    		return err
    	}
    
    	// Encrypt the private key with the password.
    	enc, err := userenckey.Encrypt(priv, []byte(curPassword))
    	if err != nil {
    		return err
    	}
    	keys := []*UserEncryptionKey{
    		&UserEncryptionKey{
    			ID:  UserEncryptionKeyMainID,
    			Key: enc,
    		},
    	}
    
    	// Update the backend database.
    	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
    		return newBackendError(err)
    	}
    	if err := tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
    		return newBackendError(err)
    	}
    
    	return nil
    }
    
    // Re-encrypt the specified user encryption key with newPassword. For this
    // operation to succeed, we must be able to decrypt one of the keys (not
    // necessarily the same one) with curPassword.
    func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword, newPassword, keyID string) error {
    	keys, err := tx.GetUserEncryptionKeys(ctx, user)
    	if err != nil {
    		return newBackendError(err)
    	}
    	keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
    	if err != nil {
    		return newRequestError(err)
    	}
    	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
    		return newBackendError(err)
    	}
    	return nil
    }
    
    // Decode the user encyrption key using the given password, then generate a new
    // list of encryption keys by replacing the specified encryption key with one
    // encrypted with the given password (or adding it if it does not exist).
    func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyID string) ([]*UserEncryptionKey, error) {
    	// userenckey.Decrypt wants a slice of []byte.
    	var rawKeys [][]byte
    	for _, k := range keys {
    		rawKeys = append(rawKeys, k.Key)
    	}
    	decrypted, err := userenckey.Decrypt(rawKeys, []byte(curPassword))
    	if err != nil {
    		return nil, err
    	}
    	encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword))
    	if err != nil {
    		return nil, err
    	}
    
    	var keysOut []*UserEncryptionKey
    	for _, key := range keys {
    		if key.ID != keyID {
    			keysOut = append(keysOut, key)
    		}
    	}
    	keysOut = append(keysOut, &UserEncryptionKey{
    		ID:  keyID,
    		Key: encrypted,
    	})
    	return keysOut, nil
    }
    
    type CreateApplicationSpecificPasswordRequest struct {
    	PrivilegedRequestBase
    	Service string `json:"service"`
    	Comment string `json:"comment"`
    }
    
    func (r *CreateApplicationSpecificPasswordRequest) Validate(_ context.Context, _ *AccountService) error {
    	if r.Service == "" {
    		return errors.New("empty 'service' attribute")
    	}
    	return nil
    }
    
    type CreateApplicationSpecificPasswordResponse struct {
    	Password string `json:"password"`
    }
    
    // CreateApplicationSpecificPassword will generate a new
    // application-specific password for the given service.
    func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, tx TX, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
    	user, err := s.authorizeUserWithPassword(ctx, tx, req.Username, req.SSO, req.CurPassword)
    	if err != nil {
    		return nil, err
    	}
    
    	if err := req.Validate(ctx, s); err != nil {
    		return nil, newRequestError(err)
    	}
    
    	// No application-specific passwords unless 2FA is enabled.
    	if !user.Has2FA {
    		return nil, newRequestError(errors.New("2FA is not enabled for this user"))
    	}
    
    	// Create a new application-specific password and set it in
    	// the database. We don't need to update the User object as
    	// we're not reusing it.
    	asp := &AppSpecificPasswordInfo{
    		ID:      randomAppSpecificPasswordID(),
    		Service: req.Service,
    		Comment: req.Comment,
    	}
    	password := randomAppSpecificPassword()
    	encPass := pwhash.Encrypt(password)
    	if err := tx.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil {
    		return nil, newBackendError(err)
    	}
    
    	// Create or update the user encryption key associated with
    	// this password. The user encryption key IDs for ASPs all
    	// have an 'asp_' prefix, followed by the ASP ID.
    	if user.HasEncryptionKeys {
    		keyID := "asp_" + asp.ID
    		if err := s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, password, keyID); err != nil {
    			return nil, err
    		}
    	}
    
    	return &CreateApplicationSpecificPasswordResponse{
    		Password: password,
    	}, nil
    }
    
    type DeleteApplicationSpecificPasswordRequest struct {
    	RequestBase
    	AspID string `json:"asp_id"`
    }
    
    // DeleteApplicationSpecificPassword destroys an application-specific
    // password, identified by its unique ID.
    func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, tx TX, req *DeleteApplicationSpecificPasswordRequest) error {
    	user, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
    	if err != nil {
    		return err
    	}
    
    	if err = tx.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil {
    		return err
    	}
    
    	// Delete the user encryption key associated with this
    	// password (we're going to find it via its ID).
    	keys, err := tx.GetUserEncryptionKeys(ctx, user)
    	if err != nil {
    		return err
    	}
    	if len(keys) == 0 {
    		return nil
    	}
    	aspKeyID := "asp_" + req.AspID
    	var newKeys []*UserEncryptionKey
    	for _, k := range keys {
    		if k.ID != aspKeyID {
    			newKeys = append(newKeys, k)
    		}
    	}
    	return tx.SetUserEncryptionKeys(ctx, user, newKeys)
    }
    
    type ChangeResourcePasswordRequest struct {
    	RequestBase
    	ResourceID ResourceID `json:"resource_id"`
    	Password   string     `json:"password"`
    }
    
    func (r *ChangeResourcePasswordRequest) Validate(ctx context.Context, s *AccountService) error {
    	return s.passwordValidator(ctx, r.Password)
    }
    
    // ChangeResourcePassword modifies the password associated with a
    // specific resource. Resources that do not support this method should
    // return an error from the backend.
    func (s *AccountService) ChangeResourcePassword(ctx context.Context, tx TX, req *ChangeResourcePasswordRequest) error {
    	_, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
    	if err != nil {
    		return err
    	}
    
    	if err = req.Validate(ctx, s); err != nil {
    		return newRequestError(err)
    	}
    
    	r, err := tx.GetResource(ctx, req.ResourceID)
    	if err != nil {
    		return newBackendError(err)
    	}
    	if r == nil {
    		return ErrResourceNotFound
    	}
    
    	encPass := pwhash.Encrypt(req.Password)
    	if err := tx.SetResourcePassword(ctx, r, encPass); err != nil {
    		return newBackendError(err)
    	}
    	return nil
    }
    
    type MoveResourceRequest struct {
    	RequestBase
    	ResourceID ResourceID `json:"resource_id"`
    	Shard      string     `json:"shard"`
    }
    
    type MoveResourceResponse struct {
    	MovedIDs []string `json:"moved_ids"`
    }
    
    // MoveResource is an administrative operation to move resources
    // between shards. Resources that are part of a group are moved all at
    // once regardless of which individual ResourceID is provided as long
    // as it belongs to the group.
    func (s *AccountService) MoveResource(ctx context.Context, tx TX, req *MoveResourceRequest) (*MoveResourceResponse, error) {
    	user, err := s.authorizeAdmin(ctx, tx, req.Username, req.SSO)
    	if err != nil {
    		return nil, err
    	}
    
    	// Collect all related resources, as they should all be moved at once.
    	r, err := tx.GetResource(ctx, req.ResourceID)
    	if err != nil {
    		return nil, err
    	}
    	var resources []*Resource
    	if r.Group != "" {
    		resources = append(resources, user.GetResourcesByGroup(r.Group)...)
    	} else {
    		resources = []*Resource{r}
    	}
    
    	var resp MoveResourceResponse
    	for _, r := range resources {
    		r.Shard = req.Shard
    		if err := tx.UpdateResource(ctx, r); err != nil {
    			return nil, err
    		}
    		resp.MovedIDs = append(resp.MovedIDs, r.ID.String())
    	}
    
    	return &resp, nil
    }
    
    type EnableOTPRequest struct {
    	RequestBase
    	TOTPSecret string `json:"totp_secret"`
    }
    
    func (r *EnableOTPRequest) Validate(_ context.Context, _ *AccountService) error {
    	// TODO: the length here is bogus, replace with real value.
    	if r.TOTPSecret != "" && len(r.TOTPSecret) != 32 {
    		return errors.New("bad totp_secret value")
    	}
    	return nil
    }
    
    type EnableOTPResponse struct {
    	TOTPSecret string `json:"totp_secret"`
    }
    
    // EnableOTP enables OTP-based two-factor authentication for a
    // user. The caller can generate the TOTP secret itself if needed
    // (useful for UX that confirms that the user is able to login first),
    // or it can let the server generate a new secret by passing an empty
    // totp_secret.
    func (s *AccountService) EnableOTP(ctx context.Context, tx TX, req *EnableOTPRequest) (*EnableOTPResponse, error) {
    	user, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
    	if err != nil {
    		return nil, err
    	}
    
    	if err = req.Validate(ctx, s); err != nil {
    		return nil, newRequestError(err)
    	}
    
    	// Replace or initialize the TOTP secret.
    	if req.TOTPSecret == "" {
    		req.TOTPSecret, err = generateTOTPSecret()
    		if err != nil {
    			return nil, err
    		}
    	}
    	if err := tx.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil {
    		return nil, newBackendError(err)
    	}
    
    	return &EnableOTPResponse{
    		TOTPSecret: req.TOTPSecret,
    	}, nil
    }
    
    type DisableOTPRequest struct {
    	RequestBase
    }
    
    // DisableOTP disables two-factor authentication for a user.
    func (s *AccountService) DisableOTP(ctx context.Context, tx TX, req *DisableOTPRequest) error {
    	user, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
    	if err != nil {
    		return err
    	}
    
    	// Delete the TOTP secret (if present).
    	if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
    		return newBackendError(err)
    	}
    	return nil
    }
    
    const appSpecificPasswordLen = 64
    
    func randomBase64(n int) string {
    	b := make([]byte, n/4*3)
    	_, err := rand.Read(b[:])
    	if err != nil {
    		panic(err)
    	}
    	return base64.StdEncoding.EncodeToString(b[:])
    }
    
    func randomAppSpecificPassword() string {
    	return randomBase64(appSpecificPasswordLen)
    }
    
    const appSpecificPasswordIDLen = 4
    
    func randomAppSpecificPasswordID() string {
    	return randomBase64(appSpecificPasswordIDLen)
    }
    
    func generateTOTPSecret() (string, error) {
    	key, err := totp.Generate(totp.GenerateOpts{})
    	if err != nil {
    		return "", err
    	}
    	return key.Secret(), nil
    }