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