Skip to content
This repository has been archived by the owner on May 10, 2024. It is now read-only.

Commit

Permalink
feat: (#299) Scoped JWT tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
B&R committed Oct 28, 2023
1 parent fc19777 commit fec4386
Show file tree
Hide file tree
Showing 17 changed files with 758 additions and 138 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ _TLDR; Primitive backup storage for E2E GPG-encrypted files, with multi-user, qu
- Configuration via GitOps (Configuration as a Code)
- Multi-tenancy with configurable Quotas
- Multiple cloud providers as a backend storage (all supported by [GO Cloud](https://gocloud.dev/howto/blob/#services))
- (Security) JWT tokens with restricted scope (login endpoint can apply additional restrictions to user session)
- (Security) Extra pairs of username & passwords with different restrictions applied - for single user

**Notice:**
- Project is more focusing on security than on performance
Expand Down
65 changes: 65 additions & 0 deletions docs/api/users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ curl -s -X POST -d '{"username":"admin","password":"admin"}' -H 'Content-Type: a
{
"data": {
"expire": "2032-02-25T00:32:56+01:00",
"msg": "Use this sessionId to revoke this token anytime",
"sessionId": "2d0aa5db61c02ea9a9d7fe6d768021aca98fff1fe75fe214a65ccd6926bb8b77",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5NjEyNzgzNzYsImxvZ2luIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTY0NTkxODM3Nn0.my0WXXMxKCetkomtzRDNIKLWUm4cJ2gxyUCkuAHT6M4"
},
Expand All @@ -34,6 +35,70 @@ curl -s -X POST -d '{"username":"admin","password":"admin"}' -H 'Content-Type: a

The `.data.token` from response is a session secret key, use it to authenticate next API requests using `Authorization: Bearer token-here` header.

### Feature: Generating a JWT token with limited permissions

User can create a restricted session with `/api/stable/auth/login` endpoint, by using extra parameter `operationsScope`.

`operationsScope` extra parameter is a working like an allowlist/whitelist - optional, when specified, then User permissions
are restricted to roles specified there.

_Notice: Roles specified in `operationsScope` cannot be higher than specified in User's profile or in collection ACL._

**Example:**

```bash
curl -s -X POST -d '{"username":"some-user","password":"test", "operationsScope": {"elements": [{"type": "collection", "name": "iwa-ait", "roles": ["collectionManager"]}]}}' -H 'Content-Type: application/json' 'http://localhost:8080/api/stable/auth/login'
```

_Notice `"operationsScope": {"elements": [{"type": "collection", "name": "iwa-ait", "roles": ["collectionManager"]}]}` in the request body._

### Feature: Access Tokens

Second way to restrict user session is by creating **Access Keys** that are separate passwords associated with same User account, but with additional restrictions.

**Example configuration:**

```yaml
---
apiVersion: backups.riotkit.org/v1alpha1
kind: BackupUser
metadata:
name: some-user
spec:
# (...)
passwordFromRef:
name: backup-repository-passwords
entry: admin
collectionAccessKeys:
#
# login: some-user$uploader
# password: test
#
- name: uploader
collections: ["iwa-ait"]
roles: ["backupUploader"]
passwordFromRef:
name: backup-repository-passwords
entry: admin_access_key_1
# (...)
```

_Notice: Roles specified in `collectionAccessKeys` cannot be higher than specified in User's profile or in collection ACL._

**Example JSON payload:**

```json
{"username":"some-user$uploader","password":"test"}
```

When using login endpoint you need to specify **Access Key** name after `$` in username field, and use password specified for that Access Key - it's not a regular password associated with the account.

**Example:**

```bash
curl -s -X POST -d '{"username":"some-user$uploader","password":"test", "operationsScope": {"elements": []}}' -H 'Content-Type: application/json' 'http://localhost:8080/api/stable/auth/login'
```

## GET `/api/stable/auth/user/some-user`

Displays information about user **of given username**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ spec:
entry: iwa-ait

accessControl:
- userName: admin
- name: admin
type: user
roles:
- collectionManager
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func main() {
ctx := core.ApplicationContainer{
Db: dbDriver,
Config: &configProvider,
Users: &usersService,
Users: usersService,
GrantedAccesses: &gaService,
JwtSecretKey: opts.JwtSecretKey,
HealthCheckKey: opts.HealthCheckKey,
Expand Down
41 changes: 21 additions & 20 deletions pkg/collections/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"github.com/labstack/gommon/bytes"
"github.com/riotkit-org/backup-repository/pkg/config"
"github.com/riotkit-org/backup-repository/pkg/security"
"github.com/riotkit-org/backup-repository/pkg/users"
"github.com/robfig/cron/v3"
"strings"
"time"
Expand Down Expand Up @@ -143,6 +142,14 @@ type Collection struct {
SecretFromSecret string
}

func (c *Collection) GetTypeName() string {
return "collection"
}

func (c *Collection) GetAccessControlList() *security.AccessControlList {
return &c.Spec.AccessControl
}

func (c *Collection) IsHealthCheckSecretValid(secret string) bool {
// secret is optional
if c.SecretFromSecret == "" {
Expand All @@ -152,31 +159,25 @@ func (c *Collection) IsHealthCheckSecretValid(secret string) bool {
}

// CanUploadToMe answers if user can add new versions to the collection
func (c *Collection) CanUploadToMe(user *users.User) bool {
if user.IsInAccessKeyContext() {
if !c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupUploader) {
return false
}
scopedRoles := user.GetAccessKeyRolesInCollectionContext(c.GetId())
return scopedRoles.HasRole(security.RoleBackupUploader) || scopedRoles.HasRole(security.RoleCollectionManager)
}
return user.GetRoles().HasRole(security.RoleCollectionManager) || user.GetRoles().HasRole(security.RoleBackupUploader) || c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupUploader)
func (c *Collection) CanUploadToMe(user security.Actor) bool {
return security.DecideCanDo(&security.DecisionRequest{
Actor: user,
Subject: c,
Action: security.ActionUpload,
})
}

// CanDownloadFromMe answers if user can download versions to this collection
func (c *Collection) CanDownloadFromMe(user *users.User) bool {
if user.IsInAccessKeyContext() {
if !c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupDownloader) {
return false
}
scopedRoles := user.GetAccessKeyRolesInCollectionContext(c.GetId())
return scopedRoles.HasRole(security.RoleBackupDownloader) || scopedRoles.HasRole(security.RoleCollectionManager)
}
return user.GetRoles().HasRole(security.RoleCollectionManager) || user.GetRoles().HasRole(security.RoleBackupDownloader) || c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupDownloader)
func (c *Collection) CanDownloadFromMe(user security.Actor) bool {
return security.DecideCanDo(&security.DecisionRequest{
Actor: user,
Subject: c,
Action: security.ActionDownload,
})
}

// CanListMyVersions answers if user can list versions
func (c *Collection) CanListMyVersions(user *users.User) bool {
func (c *Collection) CanListMyVersions(user security.Actor) bool {
return c.CanUploadToMe(user) || c.CanDownloadFromMe(user)
}

Expand Down
51 changes: 36 additions & 15 deletions pkg/http/auth.go
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
package http

import (
"encoding/json"
"errors"
"fmt"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
"github.com/riotkit-org/backup-repository/pkg/core"
"github.com/riotkit-org/backup-repository/pkg/security"
"github.com/riotkit-org/backup-repository/pkg/users"
"github.com/sirupsen/logrus"
"math/rand"
"time"
)

const (
IdentityKeyClaimIndex = "login"
AccessKeyClaimIndex = "accessKeyName"
)

type loginForm struct {
Username string `form:"username" json:"username" binding:"required"`
Password string `form:"password" json:"password" binding:"required"`

// optional
OperationsScope security.SessionLimitedOperationsScope `form:"operationsScope" json:"operationsScope"`
}

type AuthUser struct {
UserName string
AccessKeyName string
subject users.User

OperationsScope security.SessionLimitedOperationsScope
}

// Authentication middleware is used in almost every endpoint to prevalidate user credentials
Expand All @@ -35,14 +34,19 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer
authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{
Realm: "backup-repository",
Key: []byte(di.JwtSecretKey),
Timeout: time.Hour * 6,
Timeout: time.Hour * 6, // todo: configurable globally
MaxRefresh: time.Hour * 6,
IdentityKey: IdentityKeyClaimIndex,
IdentityKey: security.IdentityKeyClaimIndex,
PayloadFunc: func(data interface{}) jwt.MapClaims {
if v, ok := data.(*AuthUser); ok {
scope, scopeErr := json.Marshal(v.OperationsScope)
if scopeErr != nil {
return jwt.MapClaims{}
}
claims := jwt.MapClaims{
IdentityKeyClaimIndex: v.UserName,
AccessKeyClaimIndex: v.AccessKeyName,
security.IdentityKeyClaimIndex: v.UserName,
security.AccessKeyClaimIndex: v.AccessKeyName,
security.ScopeClaimIndex: scope,
}
rand.Seed(time.Now().UnixNano())
claims["rand"] = fmt.Sprintf("%v", rand.Intn(64))
Expand All @@ -61,9 +65,21 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer

// login user
claims := jwt.ExtractClaims(c)
username := claims[security.IdentityKeyClaimIndex].(string)

var opScope security.SessionLimitedOperationsScope
err := json.Unmarshal([]byte(claims[security.ScopeClaimIndex].(string)), &opScope)
if err != nil {
logrus.Warnf("Failed to unpack operations scope for %s", username)
return nil
}

return &AuthUser{
UserName: claims[IdentityKeyClaimIndex].(string),
AccessKeyName: claims[AccessKeyClaimIndex].(string),
UserName: username,
AccessKeyName: claims[security.AccessKeyClaimIndex].(string),

// optional
OperationsScope: opScope,
}
},
Authenticator: func(c *gin.Context) (interface{}, error) {
Expand All @@ -77,7 +93,7 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer
password := loginValues.Password
userIdentity := security.NewUserIdentityFromString(login)

user, err := di.Users.LookupUser(login)
user, err := di.Users.LookupSessionUser(userIdentity, &loginValues.OperationsScope)
logrus.Infof("Looking up user '%s' (%s)", userIdentity.Username, login)
if err != nil {
logrus.Errorf("User lookup error: %v", err)
Expand All @@ -92,6 +108,9 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer
return &AuthUser{
UserName: userIdentity.Username,
AccessKeyName: userIdentity.AccessKeyName,

// optional values
OperationsScope: loginValues.OperationsScope,
}, nil
},
Authorizator: func(data interface{}, c *gin.Context) bool {
Expand Down Expand Up @@ -123,6 +142,7 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer
"token": token,
"sessionId": hashedShortcut,
"expire": expire.Format(time.RFC3339),
"msg": "Use this sessionId to revoke this token anytime",
})
},
})
Expand All @@ -139,7 +159,8 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer
func addLookupUserRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, rateLimiter gin.HandlerFunc) {
r.GET("/api/stable/auth/user/:userName", rateLimiter, func(c *gin.Context) {
// subject
user, err := ctx.Users.LookupUser(c.Param("userName"))
identity := security.NewUserIdentityFromString(c.Param("userName"))
user, err := ctx.Users.LookupUser(identity)
if err != nil {
NotFoundResponse(c, err)
return
Expand Down
23 changes: 13 additions & 10 deletions pkg/http/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT
timeoutMiddleware := timeout.New(
timeout.WithTimeout(requestTimeout),
timeout.WithHandler(func(c *gin.Context) {
// todo: deactivate token if temporary token is used

ctxUser, _ := GetContextUser(ctx, c)

// Check if Collection exists
Expand All @@ -37,12 +35,17 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT
}

// [SECURITY] Backup Windows support
if !ctx.Collections.ValidateIsBackupWindowAllowingToUpload(collection, time.Now()) &&
!ctxUser.GetRoles().HasRole(security.RoleUploadsAnytime) {

UnauthorizedResponse(c, errors.New("backup window does not allow you to send a backup at this time. "+
"You need a token from a user that has a special permission 'uploadsAnytime'"))
return
if !ctx.Collections.ValidateIsBackupWindowAllowingToUpload(collection, time.Now()) {
uploadAnyTimeRequest := security.DecisionRequest{
Actor: ctxUser,
Subject: collection,
Action: security.ActionUploadAnytime,
}
if !security.DecideCanDo(&uploadAnyTimeRequest) {
UnauthorizedResponse(c, errors.New("backup window does not allow you to send a backup at this time. "+
"You need a token that has a special permission 'uploadsAnytime'"))
return
}
}

// [SECURITY] Do not allow parallel uploads to the same collection
Expand Down Expand Up @@ -84,14 +87,15 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT
return
}
stream, openErr = fh.Open()
defer stream.Close()
if openErr != nil {
ServerErrorResponse(c, errors.New(fmt.Sprintf("cannot open file from multipart/urlencoded form: %v", openErr)))
}
defer stream.Close()

} else {
// [HTTP] Support RAW sent data via body
stream = c.Request.Body
defer stream.Close()
}

// [VALIDATION] Middlewares
Expand All @@ -106,7 +110,6 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT
wroteLen, uploadError := ctx.Storage.UploadFile(c.Request.Context(), stream, &version, &middlewares)
if uploadError != nil {
_ = ctx.Storage.Delete(&version)

ServerErrorResponse(c, errors.New(fmt.Sprintf("cannot upload version. %v", uploadError)))
return
}
Expand Down
19 changes: 15 additions & 4 deletions pkg/http/utils.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
package http

import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/riotkit-org/backup-repository/pkg/core"
"github.com/riotkit-org/backup-repository/pkg/security"
"github.com/riotkit-org/backup-repository/pkg/users"
"strings"
)

// GetContextUser returns a User{} that is authenticated in current request
func GetContextUser(ctx *core.ApplicationContainer, c *gin.Context) (*users.User, error) {
func GetContextUser(ctx *core.ApplicationContainer, c *gin.Context) (*users.SessionAwareUser, error) {
username, accessKeyName := security.ExtractLoginFromJWT(c.GetHeader("Authorization"))
scope, scopeErr := security.ExtractSessionLimitedOperationsScopeFromJWT(c.GetHeader("Authorization"))
if scopeErr != nil {
return nil, errors.New(fmt.Sprintf("cannot create context user: %s", scopeErr.Error()))
}

identity := security.NewUserIdentityFromString(username)
identity.AccessKeyName = accessKeyName

if accessKeyName != "" {
return ctx.Users.LookupUser(username + "$" + accessKeyName)
return ctx.Users.LookupSessionUser(identity, scope)
}
return ctx.Users.LookupUser(username)
return ctx.Users.LookupSessionUser(identity, scope)
}

func GetCurrentSessionId(c *gin.Context) string {
return security.HashJWT(c.GetHeader("Authorization")[7:])
return security.HashJWT(strings.Trim(c.GetHeader("Authorization"), " ")[7:])
}
Loading

0 comments on commit fec4386

Please sign in to comment.