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 }