From 8f1ff2d1a20c4686853466eb090d0f9f2567d516 Mon Sep 17 00:00:00 2001 From: Ingo Oppermann Date: Fri, 3 Feb 2023 17:43:06 +0100 Subject: [PATCH] WIP: designing interfaces, detecting identity, enforcing policies --- app/api/api.go | 12 + http/jwt/jwt.go | 14 +- http/jwt/validator.go | 140 +++--------- http/middleware/iam/iam.go | 60 +++++ http/server.go | 2 + iam/access.go | 21 ++ iam/iam.go | 26 +++ iam/identity.go | 440 +++++++++++++++++++++++++++++++++++++ rtmp/rtmp.go | 3 + srt/srt.go | 3 + 10 files changed, 605 insertions(+), 116 deletions(-) create mode 100644 http/middleware/iam/iam.go create mode 100644 iam/access.go create mode 100644 iam/iam.go create mode 100644 iam/identity.go diff --git a/app/api/api.go b/app/api/api.go index 9610f842..da2ee327 100644 --- a/app/api/api.go +++ b/app/api/api.go @@ -25,6 +25,7 @@ import ( httpfs "github.com/datarhei/core/v16/http/fs" "github.com/datarhei/core/v16/http/jwt" "github.com/datarhei/core/v16/http/router" + "github.com/datarhei/core/v16/iam" "github.com/datarhei/core/v16/io/fs" "github.com/datarhei/core/v16/log" "github.com/datarhei/core/v16/math/rand" @@ -83,6 +84,7 @@ type api struct { httpjwt jwt.JWT update update.Checker replacer replace.Replacer + iam iam.IAM errorChan chan error @@ -381,6 +383,13 @@ func (a *api) start() error { a.sessions = sessions } + iam, err := iam.NewIAM() + if err != nil { + return fmt.Errorf("iam: %w", err) + } + + a.iam = iam + diskfs, err := fs.NewRootedDiskFilesystem(fs.RootedDiskConfig{ Root: cfg.Storage.Disk.Dir, Logger: a.log.logger.core.WithComponent("DiskFS"), @@ -874,6 +883,7 @@ func (a *api) start() error { Token: cfg.RTMP.Token, Logger: a.log.logger.rtmp, Collector: a.sessions.Collector("rtmp"), + IAM: a.iam, } if cfg.RTMP.EnableTLS { @@ -902,6 +912,7 @@ func (a *api) start() error { Token: cfg.SRT.Token, Logger: a.log.logger.core.WithComponent("SRT").WithField("address", cfg.SRT.Address), Collector: a.sessions.Collector("srt"), + IAM: a.iam, } if cfg.SRT.Log.Enable { @@ -1012,6 +1023,7 @@ func (a *api) start() error { Sessions: a.sessions, Router: router, ReadOnly: cfg.API.ReadOnly, + IAM: a.iam, } mainserverhandler, err := http.NewServer(serverConfig) diff --git a/http/jwt/jwt.go b/http/jwt/jwt.go index cbfad3a7..425a8c39 100644 --- a/http/jwt/jwt.go +++ b/http/jwt/jwt.go @@ -92,7 +92,19 @@ func New(config Config) (JWT, error) { AuthScheme: "Bearer", Claims: jwtgo.MapClaims{}, ErrorHandlerWithContext: j.ErrorHandler, - ParseTokenFunc: j.parseToken("access"), + SuccessHandler: func(c echo.Context) { + token := c.Get("user").(*jwtgo.Token) + + var subject string + if claims, ok := token.Claims.(jwtgo.MapClaims); ok { + if sub, ok := claims["sub"]; ok { + subject = sub.(string) + } + } + + c.Set("user", subject) + }, + ParseTokenFunc: j.parseToken("access"), } j.refreshConfig = middleware.JWTConfig{ diff --git a/http/jwt/validator.go b/http/jwt/validator.go index 309e1d7c..47a951ac 100644 --- a/http/jwt/validator.go +++ b/http/jwt/validator.go @@ -6,7 +6,7 @@ import ( "github.com/datarhei/core/v16/http/api" "github.com/datarhei/core/v16/http/handler/util" - "github.com/datarhei/core/v16/http/jwt/jwks" + "github.com/datarhei/core/v16/iam" jwtgo "github.com/golang-jwt/jwt/v4" "github.com/labstack/echo/v4" @@ -24,14 +24,12 @@ type Validator interface { } type localValidator struct { - username string - password string + iam iam.IAM } -func NewLocalValidator(username, password string) (Validator, error) { +func NewLocalValidator(iam iam.IAM) (Validator, error) { v := &localValidator{ - username: username, - password: password, + iam: iam, } return v, nil @@ -48,46 +46,34 @@ func (v *localValidator) Validate(c echo.Context) (bool, string, error) { return false, "", nil } - if login.Username != v.username || login.Password != v.password { + identity := v.iam.GetIdentity(login.Username) + if identity == nil { return true, "", fmt.Errorf("invalid username or password") } - return true, v.username, nil + if !identity.VerifyAPIPassword(login.Password) { + return true, "", fmt.Errorf("invalid username or password") + } + + return true, login.Username, nil } func (v *localValidator) Cancel() {} type auth0Validator struct { - domain string - issuer string - audience string - clientID string - users []string - certs jwks.JWKS + iam iam.IAM } -func NewAuth0Validator(domain, audience, clientID string, users []string) (Validator, error) { +func NewAuth0Validator(iam iam.IAM) (Validator, error) { v := &auth0Validator{ - domain: domain, - issuer: "https://" + domain + "/", - audience: audience, - clientID: clientID, - users: users, + iam: iam, } - url := v.issuer + ".well-known/jwks.json" - certs, err := jwks.NewFromURL(url, jwks.Config{}) - if err != nil { - return nil, err - } - - v.certs = certs - return v, nil } func (v auth0Validator) String() string { - return fmt.Sprintf("auth0 domain=%s audience=%s clientid=%s", v.domain, v.audience, v.clientID) + return fmt.Sprintf("auth0 domain=%s audience=%s clientid=%s", "", "", "") } func (v *auth0Validator) Validate(c echo.Context) (bool, string, error) { @@ -116,26 +102,6 @@ func (v *auth0Validator) Validate(c echo.Context) (bool, string, error) { return false, "", nil } - var issuer string - if claims, ok := token.Claims.(jwtgo.MapClaims); ok { - if iss, ok := claims["iss"]; ok { - issuer = iss.(string) - } - } - - if issuer != v.issuer { - return false, "", nil - } - - token, err = jwtgo.Parse(auth, v.keyFunc) - if err != nil { - return true, "", err - } - - if !token.Valid { - return true, "", fmt.Errorf("invalid token") - } - var subject string if claims, ok := token.Claims.(jwtgo.MapClaims); ok { if sub, ok := claims["sub"]; ok { @@ -143,72 +109,16 @@ func (v *auth0Validator) Validate(c echo.Context) (bool, string, error) { } } + identity := v.iam.GetIdentityByAuth0(subject) + if identity == nil { + return true, "", fmt.Errorf("invalid token") + } + + if !identity.VerifyAPIAuth0(auth) { + return true, "", fmt.Errorf("invalid token") + } + return true, subject, nil } -func (v *auth0Validator) keyFunc(token *jwtgo.Token) (interface{}, error) { - // Verify 'aud' claim - checkAud := token.Claims.(jwtgo.MapClaims).VerifyAudience(v.audience, false) - if !checkAud { - return nil, fmt.Errorf("invalid audience") - } - - // Verify 'iss' claim - checkIss := token.Claims.(jwtgo.MapClaims).VerifyIssuer(v.issuer, false) - if !checkIss { - return nil, fmt.Errorf("invalid issuer") - } - - // Verify 'sub' claim - if _, ok := token.Claims.(jwtgo.MapClaims)["sub"]; !ok { - return nil, fmt.Errorf("sub claim is required") - } - - sub := token.Claims.(jwtgo.MapClaims)["sub"].(string) - found := false - for _, u := range v.users { - if sub == u { - found = true - break - } - } - - if !found { - return nil, fmt.Errorf("user not allowed") - } - - // find the key - if _, ok := token.Header["kid"]; !ok { - return nil, fmt.Errorf("kid not found") - } - - kid := token.Header["kid"].(string) - - key, err := v.certs.Key(kid) - if err != nil { - return nil, fmt.Errorf("no cert for kid found: %w", err) - } - - // find algorithm - if _, ok := token.Header["alg"]; !ok { - return nil, fmt.Errorf("kid not found") - } - - alg := token.Header["alg"].(string) - - if key.Alg() != alg { - return nil, fmt.Errorf("signing method doesn't match") - } - - // get the public key - publicKey, err := key.PublicKey() - if err != nil { - return nil, fmt.Errorf("invalid public key: %w", err) - } - - return publicKey, nil -} - -func (v *auth0Validator) Cancel() { - v.certs.Cancel() -} +func (v *auth0Validator) Cancel() {} diff --git a/http/middleware/iam/iam.go b/http/middleware/iam/iam.go new file mode 100644 index 00000000..8f769481 --- /dev/null +++ b/http/middleware/iam/iam.go @@ -0,0 +1,60 @@ +package iam + +import ( + "net/http" + + "github.com/datarhei/core/v16/iam" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +type Config struct { + // Skipper defines a function to skip middleware. + Skipper middleware.Skipper + IAM iam.IAM +} + +var DefaultConfig = Config{ + Skipper: middleware.DefaultSkipper, + IAM: nil, +} + +func New() echo.MiddlewareFunc { + return NewWithConfig(DefaultConfig) +} + +func NewWithConfig(config Config) echo.MiddlewareFunc { + if config.Skipper == nil { + config.Skipper = DefaultConfig.Skipper + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if config.Skipper(c) { + return next(c) + } + + if config.IAM == nil { + return next(c) + } + + user := c.Get("user").(string) + if len(user) == 0 { + user = "$anon" + } + domain := c.QueryParam("group") + if len(domain) == 0 { + domain = "$none" + } + resource := c.Request().URL.Path + action := c.Request().Method + + if !config.IAM.Enforce(user, domain, resource, action) { + return echo.NewHTTPError(http.StatusForbidden) + } + + return next(c) + } + } +} diff --git a/http/server.go b/http/server.go index 7b4b9828..2e5967d6 100644 --- a/http/server.go +++ b/http/server.go @@ -43,6 +43,7 @@ import ( "github.com/datarhei/core/v16/http/jwt" "github.com/datarhei/core/v16/http/router" "github.com/datarhei/core/v16/http/validator" + "github.com/datarhei/core/v16/iam" "github.com/datarhei/core/v16/log" "github.com/datarhei/core/v16/monitor" "github.com/datarhei/core/v16/net" @@ -92,6 +93,7 @@ type Config struct { Sessions session.RegistryReader Router router.Router ReadOnly bool + IAM iam.IAM } type CorsConfig struct { diff --git a/iam/access.go b/iam/access.go new file mode 100644 index 00000000..51390848 --- /dev/null +++ b/iam/access.go @@ -0,0 +1,21 @@ +package iam + +type AccessEnforcer interface { + Enforce(name, domain, resource, action string) bool +} + +type AccessManager interface { + AccessEnforcer + + AddPolicy() +} + +type access struct { +} + +func NewAccessManager() (AccessManager, error) { + return &access{}, nil +} + +func (a *access) AddPolicy() {} +func (a *access) Enforce(name, domain, resource, action string) bool { return false } diff --git a/iam/iam.go b/iam/iam.go new file mode 100644 index 00000000..ba11f33b --- /dev/null +++ b/iam/iam.go @@ -0,0 +1,26 @@ +package iam + +type IAM interface { + Enforce(user, domain, resource, action string) bool + + GetIdentity(name string) IdentityVerifier + GetIdentityByAuth0(name string) IdentityVerifier +} + +type iam struct{} + +func NewIAM() (IAM, error) { + return &iam{}, nil +} + +func (i *iam) Enforce(user, domain, resource, action string) bool { + return false +} + +func (i *iam) GetIdentity(name string) IdentityVerifier { + return nil +} + +func (i *iam) GetIdentityByAuth0(name string) IdentityVerifier { + return nil +} diff --git a/iam/identity.go b/iam/identity.go new file mode 100644 index 00000000..39828210 --- /dev/null +++ b/iam/identity.go @@ -0,0 +1,440 @@ +package iam + +import ( + "fmt" + "regexp" + "sync" + + "github.com/datarhei/core/v16/http/jwt/jwks" + + jwtgo "github.com/golang-jwt/jwt/v4" +) + +// Auth0 +// there needs to be a mapping from the Auth.User to Name +// the same Auth0.User can't have multiple identities +// the whole jwks will be part of this package + +type User struct { + Name string `json:"name"` + Superuser bool `json:"superuser"` + Auth struct { + API struct { + Userpass struct { + Enable bool `json:"enable"` + Password string `json:"password"` + } `json:"userpass"` + Auth0 struct { + Enable bool `json:"enable"` + User string `json:"user"` + Tenant Auth0Tenant `json:"tenant"` + } `json:"auth0"` + } `json:"api"` + Services struct { + Basic struct { + Enable bool `json:"enable"` + Password string `json:"password"` + } `json:"basic"` + Token string `json:"token"` + } `json:"services"` + } `json:"auth"` +} + +func (u *User) validate() error { + if len(u.Name) == 0 { + return fmt.Errorf("the name is required") + } + + re := regexp.MustCompile(`[^A-Za-z0-9_-]`) + if re.MatchString(u.Name) { + return fmt.Errorf("the name can only the contain [A-Za-z0-9_-]") + } + + if u.Auth.API.Userpass.Enable && len(u.Auth.API.Userpass.Password) == 0 { + return fmt.Errorf("a password for API login is required") + } + + if u.Auth.API.Auth0.Enable && len(u.Auth.API.Auth0.User) == 0 { + return fmt.Errorf("a user for Auth0 login is required") + } + + if u.Auth.Services.Basic.Enable && len(u.Auth.Services.Basic.Password) == 0 { + return fmt.Errorf("a password for service basic auth is required") + } + + return nil +} + +func (u *User) marshalIdentity() *identity { + i := &identity{ + user: *u, + } + + return i +} + +type identity struct { + user User + + tenant *auth0Tenant + + valid bool + + lock sync.RWMutex +} + +func (i *identity) VerifyAPIPassword(password string) bool { + i.lock.RLock() + defer i.lock.RUnlock() + + if !i.isValid() { + return false + } + + if !i.user.Auth.API.Userpass.Enable { + return false + } + + return i.user.Auth.API.Userpass.Password == password +} + +func (i *identity) VerifyAPIAuth0(jwt string) bool { + i.lock.RLock() + defer i.lock.RUnlock() + + if !i.isValid() { + return false + } + + if !i.user.Auth.API.Auth0.Enable { + return false + } + + p := &jwtgo.Parser{} + token, _, err := p.ParseUnverified(jwt, jwtgo.MapClaims{}) + if err != nil { + return false + } + + var subject string + if claims, ok := token.Claims.(jwtgo.MapClaims); ok { + if sub, ok := claims["sub"]; ok { + subject = sub.(string) + } + } + + if subject != i.user.Auth.API.Auth0.User { + return false + } + + var issuer string + if claims, ok := token.Claims.(jwtgo.MapClaims); ok { + if iss, ok := claims["iss"]; ok { + issuer = iss.(string) + } + } + + if issuer != i.tenant.issuer { + return false + } + + token, err = jwtgo.Parse(jwt, i.auth0KeyFunc) + if err != nil { + return false + } + + if !token.Valid { + return false + } + + return true +} + +func (i *identity) auth0KeyFunc(token *jwtgo.Token) (interface{}, error) { + // Verify 'aud' claim + checkAud := token.Claims.(jwtgo.MapClaims).VerifyAudience(i.tenant.audience, false) + if !checkAud { + return nil, fmt.Errorf("invalid audience") + } + + // Verify 'iss' claim + checkIss := token.Claims.(jwtgo.MapClaims).VerifyIssuer(i.tenant.issuer, false) + if !checkIss { + return nil, fmt.Errorf("invalid issuer") + } + + // Verify 'sub' claim + if _, ok := token.Claims.(jwtgo.MapClaims)["sub"]; !ok { + return nil, fmt.Errorf("sub claim is required") + } + + // find the key + if _, ok := token.Header["kid"]; !ok { + return nil, fmt.Errorf("kid not found") + } + + kid := token.Header["kid"].(string) + + key, err := i.tenant.certs.Key(kid) + if err != nil { + return nil, fmt.Errorf("no cert for kid found: %w", err) + } + + // find algorithm + if _, ok := token.Header["alg"]; !ok { + return nil, fmt.Errorf("kid not found") + } + + alg := token.Header["alg"].(string) + + if key.Alg() != alg { + return nil, fmt.Errorf("signing method doesn't match") + } + + // get the public key + publicKey, err := key.PublicKey() + if err != nil { + return nil, fmt.Errorf("invalid public key: %w", err) + } + + return publicKey, nil +} + +func (i *identity) VerifyServiceBasicAuth(password string) bool { + i.lock.RLock() + defer i.lock.RUnlock() + + if !i.isValid() { + return false + } + + if !i.user.Auth.Services.Basic.Enable { + return false + } + + return i.user.Auth.Services.Basic.Password == password +} + +func (i *identity) VerifyServiceToken(token string) bool { + i.lock.RLock() + defer i.lock.RUnlock() + + if !i.isValid() { + return false + } + + return i.user.Auth.Services.Token == token +} + +func (i *identity) isValid() bool { + return i.valid +} + +func (i *identity) IsSuperuser() bool { + i.lock.RLock() + defer i.lock.RUnlock() + + return i.user.Superuser +} + +type IdentityVerifier interface { + VerifyAPIPassword(password string) bool + VerifyAPIAuth0(jwt string) bool + + VerifyServiceBasicAuth(password string) bool + VerifyServiceToken(token string) bool + + IsSuperuser() bool +} + +type IdentityManager interface { + Create(identity User) error + Remove(name string) error + Get(name string) (User, error) + GetVerifier(name string) (IdentityVerifier, error) + Rename(oldname, newname string) error + Update(name string, identity User) error + + Load(path string) error + Save(path string) error +} + +type Auth0Tenant struct { + Domain string + Audience string + ClientID string +} + +func (t *Auth0Tenant) key() string { + return t.Domain + t.Audience +} + +type auth0Tenant struct { + domain string + issuer string + audience string + clientIDs []string + certs jwks.JWKS +} + +func newAuth0Tenant(tenant Auth0Tenant) (*auth0Tenant, error) { + t := &auth0Tenant{ + domain: tenant.Domain, + issuer: "https://" + tenant.Domain + "/", + audience: tenant.Audience, + clientIDs: []string{tenant.ClientID}, + certs: nil, + } + + url := t.issuer + "/.well-known/jwks.json" + certs, err := jwks.NewFromURL(url, jwks.Config{}) + if err != nil { + return nil, err + } + + t.certs = certs + + return t, nil +} + +type identityManager struct { + identities map[string]*identity + tenants map[string]*auth0Tenant + + auth0UserIdentityMap map[string]string + + lock sync.RWMutex +} + +func NewIdentityManager() (IdentityManager, error) { + return &identityManager{ + identities: map[string]*identity{}, + tenants: map[string]*auth0Tenant{}, + auth0UserIdentityMap: map[string]string{}, + }, nil +} + +func (i *identityManager) Create(u User) error { + if err := u.validate(); err != nil { + return err + } + + i.lock.Lock() + defer i.lock.Unlock() + + _, ok := i.identities[u.Name] + if ok { + return fmt.Errorf("identity already exists") + } + + identity := u.marshalIdentity() + + if identity.user.Auth.API.Auth0.Enable { + if _, ok := i.auth0UserIdentityMap[identity.user.Auth.API.Auth0.User]; ok { + return fmt.Errorf("the Auth0 user has already an identity") + } + + auth0Key := identity.user.Auth.API.Auth0.Tenant.key() + + if _, ok := i.tenants[auth0Key]; !ok { + tenant, err := newAuth0Tenant(identity.user.Auth.API.Auth0.Tenant) + if err != nil { + return err + } + + i.tenants[auth0Key] = tenant + } + + } + + i.identities[identity.user.Name] = identity + + return nil +} + +func (i *identityManager) Update(name string, identity User) error { + return nil +} + +func (i *identityManager) Remove(name string) error { + i.lock.Lock() + defer i.lock.Unlock() + + user, ok := i.identities[name] + if !ok { + return nil + } + + delete(i.identities, name) + + user.lock.Lock() + user.valid = false + user.lock.Unlock() + + return nil +} + +func (i *identityManager) getIdentity(name string) (*identity, error) { + i.lock.RLock() + defer i.lock.RUnlock() + + identity, ok := i.identities[name] + if !ok { + return nil, fmt.Errorf("not found") + } + + return identity, nil +} + +func (i *identityManager) Get(name string) (User, error) { + i.lock.RLock() + defer i.lock.RUnlock() + + identity, ok := i.identities[name] + if !ok { + return User{}, fmt.Errorf("not found") + } + + return identity.user, nil +} + +func (i *identityManager) GetVerifier(name string) (IdentityVerifier, error) { + i.lock.RLock() + defer i.lock.RUnlock() + + identity, ok := i.identities[name] + if !ok { + return nil, fmt.Errorf("not found") + } + + return identity, nil +} + +func (i *identityManager) Rename(oldname, newname string) error { + i.lock.Lock() + defer i.lock.Unlock() + + identity, ok := i.identities[oldname] + if !ok { + return nil + } + + if _, ok := i.identities[newname]; ok { + return fmt.Errorf("the new name already exists") + } + + delete(i.identities, oldname) + + identity.user.Name = newname + i.identities[newname] = identity + + return nil +} + +func (i *identityManager) Load(path string) error { + return fmt.Errorf("not implemented") +} + +func (i *identityManager) Save(path string) error { + return fmt.Errorf("not implemented") +} diff --git a/rtmp/rtmp.go b/rtmp/rtmp.go index 4990b49d..7e010685 100644 --- a/rtmp/rtmp.go +++ b/rtmp/rtmp.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/datarhei/core/v16/iam" "github.com/datarhei/core/v16/log" "github.com/datarhei/core/v16/session" @@ -195,6 +196,8 @@ type Config struct { // ListenAndServe, so it's not possible to modify the configuration // with methods like tls.Config.SetSessionTicketKeys. TLSConfig *tls.Config + + IAM iam.IAM } // Server represents a RTMP server diff --git a/srt/srt.go b/srt/srt.go index f81d3a35..c3047943 100644 --- a/srt/srt.go +++ b/srt/srt.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/datarhei/core/v16/iam" "github.com/datarhei/core/v16/log" "github.com/datarhei/core/v16/session" srt "github.com/datarhei/gosrt" @@ -164,6 +165,8 @@ type Config struct { Collector session.Collector SRTLogTopics []string + + IAM iam.IAM } // Server represents a SRT server