github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/move/request.go (about)

     1  package move
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  	"strconv"
     9  	"time"
    10  
    11  	"github.com/cozy/cozy-stack/model/instance"
    12  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    13  	"github.com/cozy/cozy-stack/model/job"
    14  	"github.com/cozy/cozy-stack/model/oauth"
    15  	"github.com/cozy/cozy-stack/model/permission"
    16  	"github.com/cozy/cozy-stack/pkg/config/config"
    17  	"github.com/cozy/cozy-stack/pkg/consts"
    18  	"github.com/cozy/cozy-stack/pkg/couchdb"
    19  	"github.com/cozy/cozy-stack/pkg/crypto"
    20  	"github.com/cozy/cozy-stack/pkg/safehttp"
    21  	jwt "github.com/golang-jwt/jwt/v5"
    22  	multierror "github.com/hashicorp/go-multierror"
    23  	"github.com/labstack/echo/v4"
    24  )
    25  
    26  const (
    27  	// MoveScope is the scope requested for a move (when we don't know yet if
    28  	// the cozy will be the source or the target).
    29  	MoveScope = consts.ExportsRequests + " " + consts.Imports
    30  	// SourceClientID is the fake OAuth client ID used for some move endpoints.
    31  	SourceClientID = "move"
    32  )
    33  
    34  // Request is a struct for confirming a move to another Cozy.
    35  type Request struct {
    36  	IgnoreVault bool               `json:"ignore_vault,omitempty"`
    37  	SourceCreds RequestCredentials `json:"source_credentials"`
    38  	TargetCreds RequestCredentials `json:"target_credentials"`
    39  	Target      string             `json:"target"`
    40  	Link        string             `json:"-"`
    41  }
    42  
    43  // RequestCredentials is struct for OAuth credentials (access_token, client_id
    44  // and client_secret).
    45  type RequestCredentials struct {
    46  	Token        string `json:"token"`
    47  	ClientID     string `json:"client_id"`
    48  	ClientSecret string `json:"client_secret"`
    49  }
    50  
    51  // TargetHost returns the host part of the target instance address.
    52  func (r *Request) TargetHost() string {
    53  	if u, err := url.Parse(r.Target); err == nil {
    54  		return u.Host
    55  	}
    56  	return r.Target
    57  }
    58  
    59  // ImportingURL returns the URL on the target for the page to wait until
    60  // the move is done.
    61  func (r *Request) ImportingURL() string {
    62  	u, err := url.Parse(r.Target)
    63  	if err != nil {
    64  		u, err = url.Parse("https://" + r.Target)
    65  	}
    66  	if err != nil {
    67  		return r.Target
    68  	}
    69  	u.Path = "/move/importing"
    70  	return u.String()
    71  }
    72  
    73  // CreateRequestClient creates an OAuth client that can be used for move requests.
    74  func CreateRequestClient(inst *instance.Instance) (*oauth.Client, error) {
    75  	client := &oauth.Client{
    76  		RedirectURIs: []string{config.GetConfig().Move.URL + "/fake"},
    77  		ClientName:   "cozy-stack",
    78  		SoftwareID:   "github.com/cozy/cozy-stack",
    79  	}
    80  	if err := client.Create(inst, oauth.NotPending); err != nil {
    81  		return nil, errors.New(err.Error)
    82  	}
    83  	return client, nil
    84  }
    85  
    86  // CreateRequest checks if the parameters are OK for moving, and if yes, it
    87  // will persist them and return a link that can be used to confirm the move.
    88  func CreateRequest(inst *instance.Instance, params url.Values) (*Request, error) {
    89  	var source RequestCredentials
    90  	code := params.Get("code")
    91  	if code == "" {
    92  		source.ClientID = params.Get("client_id")
    93  		if source.ClientID == "" {
    94  			return nil, errors.New("No client_id")
    95  		}
    96  		source.ClientSecret = params.Get("client_secret")
    97  		if source.ClientSecret == "" {
    98  			return nil, errors.New("No client_secret")
    99  		}
   100  		source.Token = params.Get("token")
   101  		if source.Token == "" {
   102  			return nil, errors.New("No code or token")
   103  		}
   104  		if err := checkSourceToken(inst, source); err != nil {
   105  			return nil, err
   106  		}
   107  	} else {
   108  		if err := checkSourceCode(inst, code); err != nil {
   109  			return nil, err
   110  		}
   111  		client, err := CreateRequestClient(inst)
   112  		if err != nil {
   113  			return nil, err
   114  		}
   115  		client.CouchID = client.ClientID
   116  		token, err := client.CreateJWT(inst, consts.AccessTokenAudience, MoveScope)
   117  		if err != nil {
   118  			return nil, err
   119  		}
   120  		source.ClientID = client.ClientID
   121  		source.ClientSecret = client.ClientSecret
   122  		source.Token = token
   123  	}
   124  
   125  	var target RequestCredentials
   126  	cozyURL := params.Get("target_url")
   127  	if cozyURL == "" {
   128  		return nil, errors.New("No target_url")
   129  	}
   130  	if inst.HasDomain(cozyURL) {
   131  		return nil, errors.New("Invalid target_url")
   132  	}
   133  	target.Token = params.Get("target_token")
   134  	if target.Token == "" {
   135  		return nil, errors.New("No target_token")
   136  	}
   137  	target.ClientID = params.Get("target_client_id")
   138  	if target.ClientID == "" {
   139  		return nil, errors.New("No target_client_id")
   140  	}
   141  	target.ClientSecret = params.Get("target_client_secret")
   142  	if target.ClientSecret == "" {
   143  		return nil, errors.New("No target_client_secret")
   144  	}
   145  
   146  	// If the user has clicked on the "Ignore this step" button in cozy-move at
   147  	// the export the passwords page, we keep this information to not show them
   148  	// how to import the passwords on the target instance.
   149  	ignoreVault := params.Get("ignore_vault") != ""
   150  
   151  	req := &Request{
   152  		SourceCreds: source,
   153  		TargetCreds: target,
   154  		Target:      cozyURL,
   155  		IgnoreVault: ignoreVault,
   156  	}
   157  
   158  	secret, err := GetStore().SaveRequest(inst, req)
   159  	if err != nil {
   160  		return nil, err
   161  	}
   162  
   163  	req.Link = inst.PageURL("/move/go", url.Values{"secret": {secret}})
   164  	return req, nil
   165  }
   166  
   167  func checkSourceToken(inst *instance.Instance, source RequestCredentials) error {
   168  	var claims permission.Claims
   169  	err := crypto.ParseJWT(source.Token, func(token *jwt.Token) (interface{}, error) {
   170  		return inst.PickKey(consts.AccessTokenAudience)
   171  	}, &claims)
   172  	if err != nil {
   173  		return permission.ErrInvalidToken
   174  	}
   175  
   176  	if claims.Issuer != inst.Domain {
   177  		return permission.ErrInvalidToken
   178  	}
   179  	if claims.Expired() {
   180  		return permission.ErrExpiredToken
   181  	}
   182  
   183  	c, err := oauth.FindClient(inst, claims.Subject)
   184  	if err != nil {
   185  		if couchdb.IsInternalServerError(err) {
   186  			return err
   187  		}
   188  		return permission.ErrInvalidToken
   189  	}
   190  
   191  	if c.ClientID != source.ClientID {
   192  		return permission.ErrInvalidToken
   193  	}
   194  	if c.ClientSecret != source.ClientSecret {
   195  		return permission.ErrInvalidToken
   196  	}
   197  	return nil
   198  }
   199  
   200  func checkSourceCode(inst *instance.Instance, code string) error {
   201  	accessCode := &oauth.AccessCode{}
   202  	if err := couchdb.GetDoc(inst, consts.OAuthAccessCodes, code, accessCode); err != nil {
   203  		return permission.ErrInvalidToken
   204  	}
   205  	if accessCode.ClientID != SourceClientID {
   206  		return permission.ErrInvalidToken
   207  	}
   208  	if accessCode.Scope != consts.ExportsRequests {
   209  		return permission.ErrInvalidToken
   210  	}
   211  	return nil
   212  }
   213  
   214  // StartMove checks that the secret is known, sends a request to the other Cozy
   215  // to block it during the move, and pushs a job for the export.
   216  func StartMove(inst *instance.Instance, secret string) (*Request, error) {
   217  	req, err := GetStore().GetRequest(inst, secret)
   218  	if err != nil {
   219  		return nil, err
   220  	}
   221  	if req == nil {
   222  		return nil, errors.New("Invalid secret")
   223  	}
   224  
   225  	u := req.ImportingURL() + "?source=" + inst.ContextualDomain()
   226  	r, err := http.NewRequest("POST", u, nil)
   227  	if err != nil {
   228  		return nil, errors.New("Cannot reach the other Cozy")
   229  	}
   230  	r.Header.Add(echo.HeaderAuthorization, "Bearer "+req.TargetCreds.Token)
   231  	_, err = safehttp.ClientWithKeepAlive.Do(r)
   232  	if err != nil {
   233  		return nil, errors.New("Cannot reach the other Cozy")
   234  	}
   235  
   236  	doc, err := inst.SettingsDocument()
   237  	if err == nil {
   238  		doc.M["moved_to"] = req.Target
   239  		_ = couchdb.UpdateDoc(inst, doc)
   240  	}
   241  
   242  	options := ExportOptions{
   243  		ContextualDomain: inst.ContextualDomain(),
   244  		TokenSource:      req.SourceCreds.Token,
   245  		MoveTo: &MoveToOptions{
   246  			URL:          req.Target,
   247  			Token:        req.TargetCreds.Token,
   248  			ClientID:     req.TargetCreds.ClientID,
   249  			ClientSecret: req.TargetCreds.ClientSecret,
   250  		},
   251  		IgnoreVault: req.IgnoreVault,
   252  	}
   253  	msg, err := job.NewMessage(options)
   254  	if err != nil {
   255  		return nil, err
   256  	}
   257  	_, err = job.System().PushJob(inst, &job.JobRequest{
   258  		WorkerType: "export",
   259  		Message:    msg,
   260  	})
   261  	return req, err
   262  }
   263  
   264  // CallFinalize will call the /move/finalize endpoint on the other instance to
   265  // unblock it after a successful move.
   266  func CallFinalize(inst *instance.Instance, otherURL, token string, vault bool) {
   267  	u, err := url.Parse(otherURL)
   268  	if err != nil {
   269  		u, err = url.Parse("https://" + otherURL)
   270  	}
   271  	if err != nil {
   272  		return
   273  	}
   274  	u.Path = "/move/finalize"
   275  	subdomainType := "flat"
   276  	if config.GetConfig().Subdomains == config.NestedSubdomains {
   277  		subdomainType = "nested"
   278  	}
   279  	u.RawQuery = url.Values{"subdomain": {subdomainType}}.Encode()
   280  	req, err := http.NewRequest("POST", u.String(), nil)
   281  	if err != nil {
   282  		inst.Logger().
   283  			WithNamespace("move").
   284  			WithField("url", otherURL).
   285  			Warnf("Cannot finalize: %s", err)
   286  		return
   287  	}
   288  	req.Header.Add(echo.HeaderAuthorization, "Bearer "+token)
   289  	res, err := safehttp.ClientWithKeepAlive.Do(req)
   290  	if err != nil {
   291  		inst.Logger().
   292  			WithNamespace("move").
   293  			WithField("url", otherURL).
   294  			Warnf("Cannot finalize: %s", err)
   295  		return
   296  	}
   297  	defer res.Body.Close()
   298  	if res.StatusCode != 204 {
   299  		inst.Logger().
   300  			WithNamespace("move").
   301  			WithField("url", otherURL).
   302  			Warnf("Cannot finalize: code=%d", res.StatusCode)
   303  	}
   304  
   305  	doc, err := inst.SettingsDocument()
   306  	if err == nil {
   307  		doc.M["moved_from"] = u.Host
   308  		if vault {
   309  			doc.M["import_vault"] = true
   310  		}
   311  		if err := couchdb.UpdateDoc(inst, doc); err != nil {
   312  			inst.Logger().
   313  				WithNamespace("move").
   314  				WithField("moved_from", u.Host).
   315  				WithField("vault", strconv.FormatBool(vault)).
   316  				Warnf("Cannot save settings: %s", err)
   317  		}
   318  	}
   319  }
   320  
   321  // Finalize makes the last steps on the source Cozy after the data has been
   322  // successfully imported:
   323  // - stop the konnectors
   324  // - warn the OAuth clients
   325  // - unblock the instance
   326  // - ask the manager to delete the instance in one month
   327  func Finalize(inst *instance.Instance, subdomainType string) error {
   328  	var errm error
   329  	sched := job.System()
   330  	triggers, err := sched.GetAllTriggers(inst)
   331  	if err == nil {
   332  		for _, t := range triggers {
   333  			infos := t.Infos()
   334  			if infos.WorkerType == "konnector" {
   335  				if err = sched.DeleteTrigger(inst, infos.TID); err != nil {
   336  					errm = multierror.Append(errm, err)
   337  				}
   338  			}
   339  		}
   340  	} else {
   341  		errm = multierror.Append(errm, err)
   342  	}
   343  	inst.Moved = true
   344  	if err := lifecycle.Unblock(inst); err != nil {
   345  		errm = multierror.Append(errm, err)
   346  	}
   347  
   348  	doc, err := inst.SettingsDocument()
   349  	if err == nil {
   350  		doc.M["moved_to_subdomain_type"] = subdomainType
   351  		err = couchdb.UpdateDoc(inst, doc)
   352  	}
   353  	if err != nil {
   354  		errm = multierror.Append(errm, err)
   355  	}
   356  
   357  	if err := askManagerToDeleteInstance(inst); err != nil {
   358  		errm = multierror.Append(errm, err)
   359  	}
   360  
   361  	return errm
   362  }
   363  
   364  // DelayBeforeInstanceDeletionAfterMoved is the one month delay before an
   365  // instance is deleted after it has been moved to a new address.
   366  const DelayBeforeInstanceDeletionAfterMoved = 30 * 24 * time.Hour
   367  
   368  func askManagerToDeleteInstance(inst *instance.Instance) error {
   369  	if inst.UUID == "" {
   370  		return nil
   371  	}
   372  
   373  	client := instance.APIManagerClient(inst)
   374  	if client == nil {
   375  		return nil
   376  	}
   377  
   378  	ts := time.Now().Add(DelayBeforeInstanceDeletionAfterMoved)
   379  	url := fmt.Sprintf("/api/v1/instances/%s?date=%d", url.PathEscape(inst.UUID), ts.Unix())
   380  	return client.Delete(url)
   381  }
   382  
   383  // Abort will call the /move/abort endpoint on the other instance to unblock it
   384  // after a failed export or import during a move.
   385  func Abort(inst *instance.Instance, otherURL, token string) {
   386  	u, err := url.Parse(otherURL)
   387  	if err != nil {
   388  		u, err = url.Parse("https://" + otherURL)
   389  	}
   390  	if err != nil {
   391  		return
   392  	}
   393  	u.Path = "/move/abort"
   394  	req, err := http.NewRequest("POST", u.String(), nil)
   395  	if err != nil {
   396  		inst.Logger().
   397  			WithNamespace("move").
   398  			WithField("url", otherURL).
   399  			Warnf("Cannot abort: %s", err)
   400  		return
   401  	}
   402  	req.Header.Add(echo.HeaderAuthorization, "Bearer "+token)
   403  	res, err := safehttp.ClientWithKeepAlive.Do(req)
   404  	if err != nil {
   405  		inst.Logger().
   406  			WithNamespace("move").
   407  			WithField("url", otherURL).
   408  			Warnf("Cannot abort: %s", err)
   409  		return
   410  	}
   411  	defer res.Body.Close()
   412  	if res.StatusCode != 204 {
   413  		inst.Logger().
   414  			WithNamespace("move").
   415  			WithField("url", otherURL).
   416  			Warnf("Cannot abort: code=%d", res.StatusCode)
   417  	}
   418  }