github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/middlewares/permissions.go (about) 1 // Package middlewares is used for the HTTP middlewares, ie functions that 2 // takes an echo context to do stuff like checking permissions or caching 3 // requests. 4 package middlewares 5 6 import ( 7 "crypto/subtle" 8 "encoding/hex" 9 "errors" 10 "fmt" 11 "net/http" 12 "regexp" 13 "strings" 14 15 "github.com/cozy/cozy-stack/model/app" 16 "github.com/cozy/cozy-stack/model/bitwarden/settings" 17 "github.com/cozy/cozy-stack/model/instance" 18 "github.com/cozy/cozy-stack/model/oauth" 19 "github.com/cozy/cozy-stack/model/permission" 20 "github.com/cozy/cozy-stack/model/sharing" 21 "github.com/cozy/cozy-stack/model/vfs" 22 "github.com/cozy/cozy-stack/pkg/config/config" 23 "github.com/cozy/cozy-stack/pkg/consts" 24 "github.com/cozy/cozy-stack/pkg/couchdb" 25 "github.com/cozy/cozy-stack/pkg/crypto" 26 "github.com/cozy/cozy-stack/pkg/logger" 27 jwt "github.com/golang-jwt/jwt/v5" 28 "github.com/labstack/echo/v4" 29 ) 30 31 const bearerAuthScheme = "Bearer " 32 const basicAuthScheme = "Basic " 33 const contextPermissionDoc = "permissions_doc" 34 35 // ErrForbidden is used to send a forbidden response when the request does not 36 // have the right permissions. 37 var ErrForbidden = echo.NewHTTPError(http.StatusForbidden) 38 39 // ErrMissingSource is used to send a bad request when the SourceURL is missing 40 // from the request 41 var ErrMissingSource = echo.NewHTTPError(http.StatusBadRequest, "No Source in request") 42 43 var errNoToken = echo.NewHTTPError(http.StatusUnauthorized, "No token in request") 44 45 // CheckRegisterToken returns true if the registerToken is set and match the 46 // one from the instance. 47 func CheckRegisterToken(c echo.Context, i *instance.Instance) bool { 48 if len(i.RegisterToken) == 0 { 49 return false 50 } 51 hexToken := c.QueryParam("registerToken") 52 if hexToken == "" { 53 return false 54 } 55 tok, err := hex.DecodeString(hexToken) 56 if err != nil { 57 return false 58 } 59 return subtle.ConstantTimeCompare(tok, i.RegisterToken) == 1 60 } 61 62 // GetRequestToken retrieves the token from the incoming request. 63 func GetRequestToken(c echo.Context) string { 64 req := c.Request() 65 if header := req.Header.Get(echo.HeaderAuthorization); header != "" { 66 if strings.HasPrefix(header, bearerAuthScheme) { 67 return header[len(bearerAuthScheme):] 68 } 69 if strings.HasPrefix(header, basicAuthScheme) { 70 _, pass, _ := req.BasicAuth() 71 return pass 72 } 73 } 74 return c.QueryParam("bearer_token") 75 } 76 77 type linkedAppScope struct { 78 Doctype string 79 Slug string 80 } 81 82 func parseLinkedAppScope(scope string) (*linkedAppScope, error) { 83 if !strings.HasPrefix(scope, "@") { 84 return nil, fmt.Errorf("Scope %s is not a linked-app", scope) 85 } 86 splitted := strings.Split(strings.TrimPrefix(scope, "@"), "/") 87 88 return &linkedAppScope{ 89 Doctype: splitted[0], 90 Slug: splitted[1], 91 }, nil 92 } 93 94 // GetForOauth create a non-persisted permissions doc from a oauth token scopes 95 func GetForOauth(instance *instance.Instance, claims *permission.Claims, client *oauth.Client) (*permission.Permission, error) { 96 var set permission.Set 97 linkedAppScope, err := parseLinkedAppScope(claims.Scope) 98 99 if claims.Scope == "*" { 100 context := instance.ContextName 101 if context == "" { 102 context = config.DefaultInstanceContext 103 } 104 cfg := config.GetConfig().Flagship.Contexts[context] 105 skipCertification := false 106 if cfg, ok := cfg.(map[string]interface{}); ok { 107 skipCertification = cfg["skip_certification"] == true 108 } 109 if !skipCertification && !client.Flagship { 110 return nil, permission.ErrInvalidToken 111 } 112 set = permission.MaximalSet() 113 } else if err == nil && linkedAppScope != nil { 114 // Translate to a real scope 115 at := consts.NewAppType(linkedAppScope.Doctype) 116 manifest, err := app.GetBySlug(instance, linkedAppScope.Slug, at) 117 if err != nil { 118 return nil, err 119 } 120 set = manifest.Permissions() 121 } else { 122 set, err = permission.UnmarshalScopeString(claims.Scope) 123 if err != nil { 124 return nil, err 125 } 126 } 127 128 pdoc := &permission.Permission{ 129 Type: permission.TypeOauth, 130 Permissions: set, 131 SourceID: claims.Subject, 132 Client: client, 133 } 134 return pdoc, nil 135 } 136 137 var shortCodeRegexp = regexp.MustCompile(`^(\d{6}|(\w|\d){12})\.?$`) 138 139 // ExtractClaims parse a JWT, and extracts its claims (if valid). 140 func ExtractClaims(c echo.Context, instance *instance.Instance, token string) (*permission.Claims, error) { 141 var fullClaims permission.BitwardenClaims 142 var audience string 143 144 err := crypto.ParseJWT(token, func(token *jwt.Token) (interface{}, error) { 145 audiences := token.Claims.(*permission.BitwardenClaims).Claims.Audience 146 if len(audiences) != 1 { 147 return nil, permission.ErrInvalidAudience 148 } 149 audience = audiences[0] 150 return instance.PickKey(audience) 151 }, &fullClaims) 152 153 // XXX: bitwarden clients have the OAuth client ID in client_id, not subject 154 claims := fullClaims.Claims 155 if audience == consts.AccessTokenAudience && fullClaims.ClientID != "" && claims.Subject == instance.ID() { 156 claims.Subject = fullClaims.ClientID 157 } 158 159 c.Set("claims", claims) 160 161 if err != nil { 162 logger.WithNamespace("permissions").Debugf("invalid token: %s", err) 163 return nil, permission.ErrInvalidToken 164 } 165 166 // check if the claim is valid 167 if claims.Issuer != instance.Domain { 168 logger.WithNamespace("permissions"). 169 Debugf("invalid token: bad domain %s != %s", claims.Issuer, instance.Domain) 170 return nil, permission.ErrInvalidToken 171 } 172 173 if claims.Expired() { 174 logger.WithNamespace("permissions").Debugf("invalid token: expired") 175 return nil, permission.ErrExpiredToken 176 } 177 178 // If claims contains a SessionID, we check that we are actually authorized 179 // with the corresponding session. 180 if claims.SessionID != "" { 181 s, ok := GetSession(c) 182 if !ok || s.ID() != claims.SessionID { 183 if ok { 184 logger.WithNamespace("permissions"). 185 Debugf("invalid token: bad session %s != %s", s.ID(), claims.SessionID) 186 } else { 187 logger.WithNamespace("permissions"). 188 Debugf("invalid token: no session") 189 } 190 return nil, permission.ErrInvalidToken 191 } 192 } 193 194 // If claims contains a security stamp, we check that the stamp is still 195 // the same. 196 if claims.SStamp != "" { 197 settings, err := settings.Get(instance) 198 if err != nil || claims.SStamp != settings.SecurityStamp { 199 if err != nil { 200 logger.WithNamespace("permissions"). 201 Debugf("could not get instance settings: %s", err) 202 } else { 203 logger.WithNamespace("permissions"). 204 Debugf("invalid token: bad security stamp %s != %s", claims.SStamp, settings.SecurityStamp) 205 } 206 return nil, permission.ErrInvalidToken 207 } 208 } 209 210 return &claims, nil 211 } 212 213 // HasCookieForPassword returns true if a cookie has been set for the 214 // permission with a given ID if its password has been given by the user, and a 215 // cookie has been put for that. 216 func HasCookieForPassword(c echo.Context, inst *instance.Instance, permID string) bool { 217 cookieName := "pass" + permID 218 cookie, err := c.Cookie(cookieName) 219 if err != nil || cookie.Value == "" { 220 return false 221 } 222 223 cfg := crypto.MACConfig{Name: cookieName, MaxLen: 256} 224 id, err := crypto.DecodeAuthMessage(cfg, inst.SessionSecret(), []byte(cookie.Value), nil) 225 if err != nil { 226 return false 227 } 228 229 return string(id) == permID 230 } 231 232 // TransformShortcodeToJWT takes a token. If it is a short code, it transforms 233 // it to a JWT by using the associated permission. Else, it just returns the 234 // token. 235 func TransformShortcodeToJWT(inst *instance.Instance, token string) (string, error) { 236 if !shortCodeRegexp.MatchString(token) { 237 return token, nil 238 } 239 240 // XXX in theory, the shortcode is exactly 12 characters. But 241 // somethimes, when people shares a public link with this token, they 242 // can put a "." just after the link to finish their sentence, and this 243 // "." can be added to the token. So, it's better to accept a shortcode 244 // with a final ".", and clean it. 245 token = strings.TrimSuffix(token, ".") 246 return permission.GetTokenFromShortcode(inst, token) 247 } 248 249 // ParseJWT parses a JSON Web Token, and returns the associated permissions. 250 func ParseJWT(c echo.Context, instance *instance.Instance, token string) (*permission.Permission, error) { 251 token, err := TransformShortcodeToJWT(instance, token) 252 if err != nil { 253 return nil, err 254 } 255 256 claims, err := ExtractClaims(c, instance, token) 257 if err != nil { 258 if errors.Is(err, permission.ErrExpiredToken) { 259 c.Response().Header().Set(echo.HeaderWWWAuthenticate, 260 `Bearer error="invalid_token" error_description="The access token expired"`) 261 } else { 262 c.Response().Header().Set(echo.HeaderWWWAuthenticate, `Bearer error="invalid_token"`) 263 } 264 return nil, err 265 } 266 267 switch claims.AudienceString() { 268 case consts.AccessTokenAudience: 269 if err := instance.MovedError(); err != nil { 270 return nil, err 271 } 272 // An OAuth2 token is only valid if the client has not been revoked 273 client, err := oauth.FindClient(instance, claims.Subject) 274 if err != nil { 275 if couchdb.IsInternalServerError(err) { 276 return nil, err 277 } 278 logger.WithNamespace("permissions"). 279 Debugf("invalid token: no client for OAuth - %s", err) 280 c.Response().Header().Set(echo.HeaderWWWAuthenticate, `Bearer error="invalid_token"`) 281 return nil, permission.ErrInvalidToken 282 } 283 return GetForOauth(instance, claims, client) 284 285 case consts.CLIAudience: 286 // do not check client existence 287 return permission.GetForCLI(claims) 288 289 case consts.AppAudience: 290 pdoc, err := permission.GetForWebapp(instance, claims.Subject) 291 if err != nil { 292 logger.WithNamespace("permissions"). 293 Debugf("invalid token: no permission for webapp - %s", err) 294 return nil, err 295 } 296 return pdoc, nil 297 298 case consts.KonnectorAudience: 299 pdoc, err := permission.GetForKonnector(instance, claims.Subject) 300 if err != nil { 301 logger.WithNamespace("permissions"). 302 Debugf("invalid token: no permission for konnector - %s", err) 303 return nil, err 304 } 305 return pdoc, nil 306 307 case consts.ShareAudience: 308 pdoc, err := permission.GetForShareCode(instance, token) 309 if err != nil { 310 return nil, err 311 } 312 313 // Check that the password has been given for password protected share by link 314 if pdoc.Password != nil && !HasCookieForPassword(c, instance, pdoc.ID()) { 315 return nil, permission.ErrInvalidToken 316 } 317 318 // A share token is only valid if the user has not been revoked 319 if pdoc.Type == permission.TypeSharePreview || pdoc.Type == permission.TypeShareInteract { 320 sharingID := strings.Split(pdoc.SourceID, "/") 321 sharingDoc, err := sharing.FindSharing(instance, sharingID[1]) 322 if err != nil { 323 return nil, err 324 } 325 326 var member *sharing.Member 327 if pdoc.Type == permission.TypeSharePreview { 328 member, err = sharingDoc.FindMemberBySharecode(instance, token) 329 } else { 330 member, err = sharingDoc.FindMemberByInteractCode(instance, token) 331 } 332 if err != nil { 333 return nil, err 334 } 335 336 if member.Status == sharing.MemberStatusRevoked { 337 return nil, permission.ErrInvalidToken 338 } 339 340 if member.Status == sharing.MemberStatusMailNotSent || 341 member.Status == sharing.MemberStatusPendingInvitation { 342 member.Status = sharing.MemberStatusSeen 343 _ = couchdb.UpdateDoc(instance, sharingDoc) 344 } 345 } 346 347 return pdoc, nil 348 349 default: 350 return nil, echo.NewHTTPError(http.StatusBadRequest, 351 fmt.Sprintf("Unrecognized token audience %v", claims.Audience)) 352 } 353 } 354 355 // GetCLIPermission tries to extract a CLI permission from the echo context 356 // without tampering with the response headers in case the token is invalid. 357 func GetCLIPermission(c echo.Context) (*permission.Permission, bool) { 358 var err error 359 360 pdoc, ok := c.Get(contextPermissionDoc).(*permission.Permission) 361 if ok && pdoc != nil && pdoc.Type == permission.TypeCLI { 362 return pdoc, true 363 } 364 365 instance := GetInstance(c) 366 367 token := GetRequestToken(c) 368 if token == "" { 369 return nil, false 370 } 371 372 claims, err := ExtractClaims(c, instance, token) 373 if err != nil { 374 return nil, false 375 } 376 377 if claims.AudienceString() == consts.CLIAudience { 378 if pdoc, err := permission.GetForCLI(claims); err != nil { 379 c.Set(contextPermissionDoc, pdoc) 380 return pdoc, true 381 } 382 } 383 384 return nil, false 385 } 386 387 // GetPermission extracts the permission from the echo context and checks their validity 388 func GetPermission(c echo.Context) (*permission.Permission, error) { 389 var err error 390 391 pdoc, ok := c.Get(contextPermissionDoc).(*permission.Permission) 392 if ok && pdoc != nil { 393 return pdoc, nil 394 } 395 396 inst := GetInstance(c) 397 if CheckRegisterToken(c, inst) { 398 return permission.GetForRegisterToken(), nil 399 } 400 401 tok := GetRequestToken(c) 402 if tok == "" { 403 return nil, errNoToken 404 } 405 406 pdoc, err = ParseJWT(c, inst, tok) 407 if err != nil { 408 return nil, err 409 } 410 411 c.Set(contextPermissionDoc, pdoc) 412 return pdoc, nil 413 } 414 415 // AllowWholeType validates that the context permission set can use a verb on 416 // the whold doctype 417 func AllowWholeType(c echo.Context, v permission.Verb, doctype string) error { 418 pdoc, err := GetPermission(c) 419 if err != nil { 420 return err 421 } 422 if !pdoc.Permissions.AllowWholeType(v, doctype) { 423 return ErrForbidden 424 } 425 return nil 426 } 427 428 // Allow validates the validable object against the context permission set 429 func Allow(c echo.Context, v permission.Verb, o permission.Fetcher) error { 430 pdoc, err := GetPermission(c) 431 if err != nil { 432 return err 433 } 434 if !pdoc.Permissions.Allow(v, o) { 435 return ErrForbidden 436 } 437 return nil 438 } 439 440 // AllowOnFields validates the validable object againt the context permission 441 // set and ensure the selector validates the given fields. 442 func AllowOnFields(c echo.Context, v permission.Verb, o permission.Fetcher, fields ...string) error { 443 pdoc, err := GetPermission(c) 444 if err != nil { 445 return err 446 } 447 if !pdoc.Permissions.AllowOnFields(v, o, fields...) { 448 return ErrForbidden 449 } 450 return nil 451 } 452 453 // AllowTypeAndID validates a type & ID against the context permission set 454 func AllowTypeAndID(c echo.Context, v permission.Verb, doctype, id string) error { 455 pdoc, err := GetPermission(c) 456 if err != nil { 457 return err 458 } 459 if !pdoc.Permissions.AllowID(v, doctype, id) { 460 return ErrForbidden 461 } 462 return nil 463 } 464 465 // AllowVFS validates a vfs.Fetcher against the context permission set 466 func AllowVFS(c echo.Context, v permission.Verb, o vfs.Fetcher) error { 467 instance := GetInstance(c) 468 pdoc, err := GetPermission(c) 469 if err != nil { 470 return err 471 } 472 if pdoc.Permissions.IsMaximal() { 473 return nil 474 } 475 err = vfs.Allows(instance.VFS(), pdoc.Permissions, v, o) 476 if err != nil { 477 return ErrForbidden 478 } 479 return nil 480 } 481 482 // CanWriteToAnyDirectory checks that the context permission allows to write to 483 // a directory on the VFS. 484 func CanWriteToAnyDirectory(c echo.Context) error { 485 pdoc, err := GetPermission(c) 486 if err != nil { 487 return err 488 } 489 for _, rule := range pdoc.Permissions { 490 if permission.MatchType(rule, consts.Files) && rule.Verbs.Contains(permission.POST) { 491 return nil 492 } 493 } 494 return ErrForbidden 495 } 496 497 // AllowInstallApp checks that the current context is tied to the store app, 498 // which is the only app authorized to install or update other apps. 499 // It also allow the cozy-stack apps commands to work (CLI). 500 func AllowInstallApp(c echo.Context, appType consts.AppType, sourceURL string, v permission.Verb) error { 501 pdoc, err := GetPermission(c) 502 if err != nil { 503 return err 504 } 505 506 if pdoc.Permissions.IsMaximal() { 507 return nil 508 } 509 510 var docType string 511 switch appType { 512 case consts.KonnectorType: 513 docType = consts.Konnectors 514 case consts.WebappType: 515 docType = consts.Apps 516 } 517 518 if docType == "" { 519 return fmt.Errorf("unknown application type %s", appType.String()) 520 } 521 switch pdoc.Type { 522 case permission.TypeCLI: 523 // OK 524 case permission.TypeWebapp, permission.TypeKonnector: 525 if pdoc.SourceID != consts.Apps+"/"+consts.StoreSlug { 526 inst := GetInstance(c) 527 ctxSettings, ok := inst.SettingsContext() 528 if !ok || ctxSettings["allow_install_via_a_permission"] != true { 529 return ErrForbidden 530 } 531 } 532 // The store can only install apps and konnectors from the registry 533 if !strings.HasPrefix(sourceURL, "registry://") { 534 return ErrForbidden 535 } 536 case permission.TypeOauth: 537 // If the context allows to install an app via a permission, this 538 // permission can also be used by mobile apps to install apps from the 539 // registry. 540 inst := GetInstance(c) 541 ctxSettings, ok := inst.SettingsContext() 542 if !ok || ctxSettings["allow_install_via_a_permission"] != true { 543 return ErrForbidden 544 } 545 if !strings.HasPrefix(sourceURL, "registry://") { 546 return ErrForbidden 547 } 548 default: 549 return ErrForbidden 550 } 551 if !pdoc.Permissions.AllowWholeType(v, docType) { 552 return ErrForbidden 553 } 554 return nil 555 } 556 557 // AllowForKonnector checks that the permissions is valid and comes from the 558 // konnector with the given slug. 559 func AllowForKonnector(c echo.Context, slug string) error { 560 if slug == "" { 561 return ErrForbidden 562 } 563 pdoc, err := GetPermission(c) 564 if err != nil { 565 return err 566 } 567 if pdoc.Type != permission.TypeKonnector { 568 return ErrForbidden 569 } 570 permSlug := strings.TrimPrefix(pdoc.SourceID, consts.Konnectors+"/") 571 if permSlug != slug { 572 return ErrForbidden 573 } 574 return nil 575 } 576 577 // AllowLogout checks if the current permission allows logging out. 578 // all apps can trigger a logout. 579 func AllowLogout(c echo.Context) bool { 580 return HasWebAppToken(c) 581 } 582 583 // AllowMaximal checks that the permission is for the flagship app. 584 func AllowMaximal(c echo.Context) error { 585 pdoc, err := GetPermission(c) 586 if err != nil { 587 return err 588 } 589 if !pdoc.Permissions.IsMaximal() { 590 return ErrForbidden 591 } 592 return nil 593 } 594 595 // RequireSettingsApp checks that the permission is for the settings app. 596 func RequireSettingsApp(c echo.Context) error { 597 pdoc, err := GetPermission(c) 598 if err != nil { 599 return err 600 } 601 settingsSourceID := consts.Apps + "/" + consts.SettingsSlug 602 if pdoc.Type != permission.TypeWebapp || pdoc.SourceID != settingsSourceID { 603 return ErrForbidden 604 } 605 return nil 606 } 607 608 // HasWebAppToken returns true if the request comes from a web app (with a token). 609 func HasWebAppToken(c echo.Context) bool { 610 pdoc, err := GetPermission(c) 611 if err != nil { 612 return false 613 } 614 return pdoc.Type == permission.TypeWebapp 615 } 616 617 // GetOAuthClient returns the OAuth client used for making the HTTP request. 618 func GetOAuthClient(c echo.Context) (*oauth.Client, bool) { 619 perm, err := GetPermission(c) 620 if err != nil || perm.Type != permission.TypeOauth || perm.Client == nil { 621 return nil, false 622 } 623 return perm.Client.(*oauth.Client), true 624 }