snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
40
server/pkg/auth/errors.go
Normal file
40
server/pkg/auth/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
)
|
||||
|
||||
func errUnauthorized() error {
|
||||
return errors.New(
|
||||
errors.KindUnauthorized,
|
||||
|
||||
"unauthorized",
|
||||
|
||||
errors.Meta("type", "unauthorized"),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "internal"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "auth.errors.unauthorized"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
errors.StackTrimAtFn("http.HandlerFunc.ServeHTTP"),
|
||||
)
|
||||
}
|
||||
|
||||
func errUnauthorizedScope() error {
|
||||
return errors.New(
|
||||
errors.KindUnauthorized,
|
||||
|
||||
"unauthorized scope",
|
||||
|
||||
errors.Meta("type", "unauthorizedScope"),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "internal"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "auth.errors.unauthorizedScope"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
errors.StackTrimAtFn("http.HandlerFunc.ServeHTTP"),
|
||||
)
|
||||
}
|
||||
31
server/pkg/auth/extra.go
Normal file
31
server/pkg/auth/extra.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type (
|
||||
ExtraReqInfo struct {
|
||||
RemoteAddr string
|
||||
UserAgent string
|
||||
}
|
||||
)
|
||||
|
||||
func ExtraReqInfoMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ExtraReqInfo{}, ExtraReqInfo{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
})))
|
||||
})
|
||||
}
|
||||
|
||||
func GetExtraReqInfoFromContext(ctx context.Context) ExtraReqInfo {
|
||||
eti := ctx.Value(ExtraReqInfo{})
|
||||
if eti != nil {
|
||||
return eti.(ExtraReqInfo)
|
||||
} else {
|
||||
return ExtraReqInfo{}
|
||||
}
|
||||
}
|
||||
60
server/pkg/auth/identity.go
Normal file
60
server/pkg/auth/identity.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
identity struct {
|
||||
id uint64
|
||||
memberOf []uint64
|
||||
}
|
||||
)
|
||||
|
||||
// Anonymous constructs and returns new anonymous identity with system anonymous roles
|
||||
func Anonymous() *identity {
|
||||
return &identity{
|
||||
memberOf: AnonymousRoles().IDs(),
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated constructs and returns new authenticated identity with assigned roles + system authenticated roles
|
||||
func Authenticated(id uint64, rr ...uint64) *identity {
|
||||
return &identity{
|
||||
id: id,
|
||||
memberOf: append(rr, AuthenticatedRoles().IDs()...),
|
||||
}
|
||||
}
|
||||
|
||||
func (i identity) Identity() uint64 {
|
||||
return i.id
|
||||
}
|
||||
|
||||
func (i identity) Roles() []uint64 {
|
||||
return i.memberOf
|
||||
}
|
||||
|
||||
func (i identity) Valid() bool {
|
||||
return i.id > 0
|
||||
}
|
||||
|
||||
func (i identity) String() string {
|
||||
return fmt.Sprintf("%d", i.Identity())
|
||||
}
|
||||
|
||||
func ExtractFromSubClaim(sub string) (userID uint64, rr []uint64) {
|
||||
parts := strings.Split(sub, " ")
|
||||
rr = make([]uint64, len(parts)-1)
|
||||
for p := range parts {
|
||||
id, _ := strconv.ParseUint(parts[p], 10, 64)
|
||||
if p == 0 {
|
||||
userID = id
|
||||
} else {
|
||||
rr[p-1] = id
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
32
server/pkg/auth/identity_context.go
Normal file
32
server/pkg/auth/identity_context.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type (
|
||||
identityCtxKey struct{}
|
||||
)
|
||||
|
||||
func SetIdentityToContext(ctx context.Context, identity Identifiable) context.Context {
|
||||
return context.WithValue(ctx, identityCtxKey{}, identity)
|
||||
}
|
||||
|
||||
// GetIdentityFromContext always returns identity, either valid or anonymous
|
||||
//
|
||||
// For anonymous user, it auto appends all anonymous defined on the system
|
||||
func GetIdentityFromContext(ctx context.Context) Identifiable {
|
||||
if i := GetIdentityFromContextWithKey(ctx, identityCtxKey{}); i != nil && i.Valid() {
|
||||
return i
|
||||
} else {
|
||||
return Anonymous()
|
||||
}
|
||||
}
|
||||
|
||||
func GetIdentityFromContextWithKey(ctx context.Context, key interface{}) Identifiable {
|
||||
if i, ok := ctx.Value(key).(Identifiable); ok {
|
||||
return i
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
26
server/pkg/auth/interfaces.go
Normal file
26
server/pkg/auth/interfaces.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package auth
|
||||
|
||||
type (
|
||||
Identifiable interface {
|
||||
Identity() uint64
|
||||
Roles() []uint64
|
||||
Valid() bool
|
||||
String() string
|
||||
}
|
||||
|
||||
//TokenGenerator interface {
|
||||
// Encode(i Identifiable, clientID uint64, scope ...string) (token []byte, err error)
|
||||
// Generate(ctx context.Context, i Identifiable, clientID uint64, scope ...string) (token []byte, err error)
|
||||
//}
|
||||
|
||||
//TokenHandler interface {
|
||||
// TokenGenerator
|
||||
// HttpVerifier() func(http.Handler) http.Handler
|
||||
// HttpAuthenticator() func(http.Handler) http.Handler
|
||||
//}
|
||||
|
||||
Signer interface {
|
||||
Sign(userID uint64, pp ...interface{}) string
|
||||
Verify(signature string, userID uint64, pp ...interface{}) bool
|
||||
}
|
||||
)
|
||||
41
server/pkg/auth/role-security.go
Normal file
41
server/pkg/auth/role-security.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/slice"
|
||||
)
|
||||
|
||||
// ApplyRoleSecurity takes role security params (set of permitted, prohibited and forced roles)
|
||||
// and applies these rules to the set of given roles
|
||||
//
|
||||
// Filtered set of roles is returned
|
||||
//
|
||||
// String slices are used intentionally, because of the data source used
|
||||
func ApplyRoleSecurity(permitted, prohibited, forced []uint64, rr ...uint64) (out []uint64) {
|
||||
var (
|
||||
mPermitted = slice.ToUint64BoolMap(permitted)
|
||||
mProhibited = slice.ToUint64BoolMap(prohibited)
|
||||
mForced = slice.ToUint64BoolMap(forced)
|
||||
)
|
||||
|
||||
// iterate over user's roles and just append them (obeying allow&deny rules)
|
||||
// to list of mForced roles
|
||||
for _, r := range rr {
|
||||
if (len(mPermitted) == 0 || mPermitted[r]) && !mProhibited[r] {
|
||||
mForced[r] = true
|
||||
}
|
||||
}
|
||||
|
||||
out = make([]uint64, 0, len(mForced))
|
||||
for forcedRoleID := range mForced {
|
||||
out = append(out, forcedRoleID)
|
||||
}
|
||||
|
||||
// for stable output
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i] < out[j]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
56
server/pkg/auth/role-security_test.go
Normal file
56
server/pkg/auth/role-security_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyRoleSecurity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permitted []uint64
|
||||
prohibited []uint64
|
||||
forced []uint64
|
||||
roles []uint64
|
||||
wantOut []uint64
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
},
|
||||
{
|
||||
"nil",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
[]uint64{},
|
||||
},
|
||||
{
|
||||
"one",
|
||||
[]uint64{1},
|
||||
[]uint64{2},
|
||||
[]uint64{3},
|
||||
[]uint64{1, 2},
|
||||
[]uint64{1, 3},
|
||||
},
|
||||
{
|
||||
"forced only",
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{3, 2, 1},
|
||||
[]uint64{2},
|
||||
[]uint64{1, 2, 3},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.wantOut, ApplyRoleSecurity(tt.permitted, tt.prohibited, tt.forced, tt.roles...))
|
||||
})
|
||||
}
|
||||
}
|
||||
45
server/pkg/auth/scope.go
Normal file
45
server/pkg/auth/scope.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
)
|
||||
|
||||
const (
|
||||
scopeDelimiter = " "
|
||||
)
|
||||
|
||||
// CheckJwtScope verifies if required scope is in claim
|
||||
// We're using interface{} and casting it if needed to simplify usage of the fn by directly
|
||||
// using it with map[string]interface{} claims type
|
||||
func CheckJwtScope(token jwt.Token, required ...string) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
scopeClaimRaw, has := token.Get("scope")
|
||||
if !has {
|
||||
return false
|
||||
}
|
||||
|
||||
scopeClaim, ok := scopeClaimRaw.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return CheckScope(scopeClaim, required...)
|
||||
}
|
||||
|
||||
func CheckScope(scope string, required ...string) bool {
|
||||
scope = scopeDelimiter + strings.TrimSpace(scope) + scopeDelimiter
|
||||
|
||||
for _, req := range required {
|
||||
req = scopeDelimiter + strings.TrimSpace(req) + scopeDelimiter
|
||||
if strings.Contains(scope, req) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
48
server/pkg/auth/signer.go
Normal file
48
server/pkg/auth/signer.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
hmacSigner struct {
|
||||
secret []byte
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
hmacSumStringLength = 40
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultSigner Signer
|
||||
)
|
||||
|
||||
func SetupSigner(secret string) (err error) {
|
||||
DefaultSigner = HmacSigner(secret)
|
||||
return
|
||||
}
|
||||
|
||||
func HmacSigner(secret string) *hmacSigner {
|
||||
return &hmacSigner{
|
||||
secret: []byte(secret),
|
||||
}
|
||||
}
|
||||
|
||||
func (s hmacSigner) Sign(userID uint64, pp ...interface{}) string {
|
||||
h := hmac.New(sha1.New, s.secret)
|
||||
fmt.Fprintf(h, "%d ", userID)
|
||||
|
||||
for _, part := range pp {
|
||||
fmt.Fprintf(h, "%v ", part)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (s hmacSigner) Verify(signature string, userID uint64, pp ...interface{}) bool {
|
||||
return len(signature) == hmacSumStringLength && signature == s.Sign(userID, pp...)
|
||||
}
|
||||
90
server/pkg/auth/system.go
Normal file
90
server/pkg/auth/system.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProvisionUserHandle = "corteza-provisioner"
|
||||
ServiceUserHandle = "corteza-service"
|
||||
FederationUserHandle = "corteza-federation"
|
||||
|
||||
BypassRoleHandle = "super-admin"
|
||||
AuthenticatedRoleHandle = "authenticated"
|
||||
AnonymousRoleHandle = "anonymous"
|
||||
)
|
||||
|
||||
var (
|
||||
provisionUser *types.User
|
||||
serviceUser *types.User
|
||||
federationUser *types.User
|
||||
|
||||
authenticatedRoles []*types.Role
|
||||
anonymousRoles []*types.Role
|
||||
bypassRoles []*types.Role
|
||||
)
|
||||
|
||||
// SetSystemUsers takes list of users and sets/updates appropriate
|
||||
// provision, service & fed users
|
||||
//
|
||||
// These are then accessed via special *User() fn.
|
||||
func SetSystemUsers(uu types.UserSet, rr types.RoleSet) {
|
||||
bpr := rr.FindByHandle(BypassRoleHandle)
|
||||
|
||||
for _, u := range uu {
|
||||
switch u.Handle {
|
||||
case ProvisionUserHandle:
|
||||
provisionUser = u.Clone()
|
||||
provisionUser.SetRoles(bpr.ID)
|
||||
case ServiceUserHandle:
|
||||
serviceUser = u.Clone()
|
||||
serviceUser.SetRoles(bpr.ID)
|
||||
case FederationUserHandle:
|
||||
federationUser = u.Clone()
|
||||
federationUser.SetRoles(bpr.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SetSystemRoles(rr types.RoleSet) {
|
||||
for _, r := range rr {
|
||||
switch r.Handle {
|
||||
case BypassRoleHandle:
|
||||
bypassRoles = append(bypassRoles, r.Clone())
|
||||
case AuthenticatedRoleHandle:
|
||||
authenticatedRoles = append(authenticatedRoles, r.Clone())
|
||||
case AnonymousRoleHandle:
|
||||
anonymousRoles = append(anonymousRoles, r.Clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BypassRoles returns all bypass Roles
|
||||
func BypassRoles() types.RoleSet {
|
||||
return bypassRoles
|
||||
}
|
||||
|
||||
// AuthenticatedRoles returns all authenticated Roles
|
||||
func AuthenticatedRoles() types.RoleSet {
|
||||
return authenticatedRoles
|
||||
}
|
||||
|
||||
// AnonymousRoles returns all anonymous Roles
|
||||
func AnonymousRoles() types.RoleSet {
|
||||
return anonymousRoles
|
||||
}
|
||||
|
||||
// ProvisionUser returns clone of system provision user
|
||||
func ProvisionUser() *types.User {
|
||||
return provisionUser.Clone()
|
||||
}
|
||||
|
||||
// ServiceUser returns clone of system service user
|
||||
func ServiceUser() *types.User {
|
||||
return serviceUser.Clone()
|
||||
}
|
||||
|
||||
// FederationUser returns clone of system federation user
|
||||
func FederationUser() *types.User {
|
||||
return federationUser.Clone()
|
||||
}
|
||||
394
server/pkg/auth/token_issuer.go
Normal file
394
server/pkg/auth/token_issuer.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/payload"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/go-oauth2/oauth2/v4/generates"
|
||||
"github.com/go-oauth2/oauth2/v4/models"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
tokenIssuer struct {
|
||||
defaultRequest *TokenRequest
|
||||
|
||||
// store issued tokens
|
||||
store tokenIssuerStore
|
||||
|
||||
// lookup for issued tokens
|
||||
lookup tokenIssuerLookup
|
||||
|
||||
// generator for issued tokens
|
||||
generator tokenIssuerGenerator
|
||||
|
||||
// signer for issued tokens
|
||||
signer tokenIssuerSigner
|
||||
}
|
||||
|
||||
// IssuerOptFn modify toeknIssuer
|
||||
IssuerOptFn func(*tokenIssuer) error
|
||||
|
||||
TokenRequest struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
Expiration time.Duration
|
||||
Audience string
|
||||
Issuer string
|
||||
IssuedAt time.Time
|
||||
ClientID uint64
|
||||
UserID uint64
|
||||
Roles []uint64
|
||||
Scope []string
|
||||
}
|
||||
|
||||
// IssueOptFn functions modify TokenRequest
|
||||
IssueOptFn func(*TokenRequest) error
|
||||
|
||||
tokenIssuerStore func(context.Context, TokenRequest) error
|
||||
tokenIssuerLookup func(context.Context, string) error
|
||||
tokenIssuerGenerator func(context.Context, TokenRequest) (string, string, error)
|
||||
tokenIssuerSigner func(token jwt.Token) ([]byte, error)
|
||||
)
|
||||
|
||||
var (
|
||||
TokenIssuer *tokenIssuer
|
||||
|
||||
// wrapper around time.Now() that will aid service testing
|
||||
now = func() *time.Time {
|
||||
c := time.Now().Truncate(time.Second)
|
||||
return &c
|
||||
}
|
||||
)
|
||||
|
||||
// NewTokenIssuer initializes and returns new instance of JWT manager
|
||||
func NewTokenIssuer(opt ...IssuerOptFn) (issuer *tokenIssuer, err error) {
|
||||
issuer = &tokenIssuer{
|
||||
defaultRequest: &TokenRequest{Issuer: "cortezaproject.org"},
|
||||
|
||||
store: func(ctx context.Context, request TokenRequest) error {
|
||||
// elegantly handle unconfigured store
|
||||
return fmt.Errorf("token issuer store not configured")
|
||||
},
|
||||
|
||||
lookup: func(context.Context, string) error {
|
||||
// elegantly handle unconfigured lookup
|
||||
return fmt.Errorf("token issuer lookup not configured")
|
||||
},
|
||||
|
||||
signer: func(token jwt.Token) ([]byte, error) {
|
||||
// elegantly handle unconfigured signer
|
||||
return nil, fmt.Errorf("token issuer signer not configured")
|
||||
},
|
||||
|
||||
generator: DefaultAccessTokenGenerator,
|
||||
}
|
||||
|
||||
return issuer, applyToIssuer(issuer, opt...)
|
||||
}
|
||||
|
||||
// Issue issues new access token, stores it and returns signed JWT.
|
||||
func (i *tokenIssuer) Issue(ctx context.Context, opt ...IssueOptFn) (_ []byte, err error) {
|
||||
var req = i.newTokenRequest()
|
||||
if err = req.apply(opt...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.AccessToken+req.RefreshToken) > 0 {
|
||||
panic("can not issue new token with preset access and refresh tokens, " +
|
||||
"this is most likely an implementation mistake")
|
||||
}
|
||||
|
||||
if req.AccessToken, req.RefreshToken, err = i.generator(ctx, *req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = i.store(ctx, *req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return i.sign(req)
|
||||
}
|
||||
|
||||
func (i *tokenIssuer) Sign(opt ...IssueOptFn) (_ []byte, err error) {
|
||||
var req = i.newTokenRequest()
|
||||
if err = req.apply(opt...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return i.sign(req)
|
||||
}
|
||||
|
||||
func (i *tokenIssuer) sign(req *TokenRequest) ([]byte, error) {
|
||||
if token, err := makeToken(req); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return i.signer(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns new token request and copies relevant values from the default token request on the issuer
|
||||
func (i *tokenIssuer) newTokenRequest() *TokenRequest {
|
||||
return &TokenRequest{
|
||||
Issuer: i.defaultRequest.Issuer,
|
||||
ClientID: i.defaultRequest.ClientID,
|
||||
Expiration: i.defaultRequest.Expiration,
|
||||
IssuedAt: *now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate performs token validation by checking existence of access-token in the store
|
||||
func (i *tokenIssuer) Validate(ctx context.Context, token jwt.Token) (err error) {
|
||||
if err = i.lookup(ctx, token.JwtID()); err != nil {
|
||||
return errUnauthorized()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeToken(req *TokenRequest) (_ jwt.Token, err error) {
|
||||
var (
|
||||
roles = make([]string, len(req.Roles))
|
||||
token = jwt.New()
|
||||
|
||||
toString = func(i uint64) string {
|
||||
return strconv.FormatUint(i, 10)
|
||||
}
|
||||
)
|
||||
|
||||
if len(req.Scope) == 0 {
|
||||
// for backward compatibility we default
|
||||
// unset scope to profile & api
|
||||
req.Scope = []string{"profile", "api"}
|
||||
}
|
||||
|
||||
if req.IssuedAt.IsZero() {
|
||||
req.IssuedAt = *now()
|
||||
}
|
||||
|
||||
for i, r := range req.Roles {
|
||||
roles[i] = toString(r)
|
||||
}
|
||||
|
||||
// The key part: store access token as JWT ID claim.
|
||||
// Claim will be extracted when JWT is validated and checked
|
||||
if err = token.Set(jwt.JwtIDKey, req.AccessToken); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.SubjectKey, toString(req.UserID)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.ExpirationKey, now().Add(req.Expiration).Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Audience != "" {
|
||||
if err = token.Set(jwt.AudienceKey, req.Audience); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.IssuerKey, req.Issuer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.IssuedAtKey, req.IssuedAt.Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("clientID", toString(req.ClientID)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("scope", strings.Join(req.Scope, " ")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("roles", roles); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// IdentityFromToken decodes sub & roles claims into identity
|
||||
func IdentityFromToken(token jwt.Token) *identity {
|
||||
if token == nil {
|
||||
return Anonymous()
|
||||
}
|
||||
|
||||
var (
|
||||
roles, _ = token.Get("roles")
|
||||
)
|
||||
|
||||
return Authenticated(
|
||||
cast.ToUint64(token.Subject()),
|
||||
payload.ParseUint64s(cast.ToStringSlice(roles))...,
|
||||
)
|
||||
}
|
||||
|
||||
// DefaultAccessTokenGenerator uses token generator from oauth2 lib
|
||||
func DefaultAccessTokenGenerator(ctx context.Context, req TokenRequest) (string, string, error) {
|
||||
return generates.NewAccessGenerate().Token(
|
||||
ctx,
|
||||
&oauth2.GenerateBasic{
|
||||
Client: &models.Client{
|
||||
ID: strconv.FormatUint(req.ClientID, 10),
|
||||
},
|
||||
UserID: strconv.FormatUint(req.UserID, 10),
|
||||
CreateAt: *now(),
|
||||
TokenInfo: nil,
|
||||
Request: nil,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// WithSecretSigner configures token issuer with
|
||||
func WithSecretSigner(secret string) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
if len(secret) == 0 {
|
||||
return fmt.Errorf("JWK missing")
|
||||
}
|
||||
|
||||
var key jwk.Key
|
||||
if key, err = jwk.New([]byte(secret)); err != nil {
|
||||
return fmt.Errorf("could not parse JWK: %w", err)
|
||||
}
|
||||
|
||||
tm.signer = func(token jwt.Token) ([]byte, error) {
|
||||
return jwt.Sign(token, jwa.HS512, key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultClientID configures ID of the default auth client
|
||||
func WithDefaultClientID(ID uint64) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.defaultRequest.ClientID = ID
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultExpiration configures default token expiration time
|
||||
func WithDefaultExpiration(exp time.Duration) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.defaultRequest.Expiration = exp
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultIssuer configures default issuer claim
|
||||
func WithDefaultIssuer(iss string) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.defaultRequest.Issuer = iss
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithStore configures store function
|
||||
func WithStore(fn tokenIssuerStore) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.store = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithLookup configures lookup function
|
||||
func WithLookup(fn tokenIssuerLookup) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.lookup = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithGenerator configures generator function
|
||||
func WithGenerator(fn tokenIssuerGenerator) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.generator = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithSigner configures signer function
|
||||
func WithSigner(fn tokenIssuerSigner) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.signer = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (req *TokenRequest) apply(opt ...IssueOptFn) (err error) {
|
||||
for _, fn := range opt {
|
||||
if err = fn(req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func WithExpiration(e time.Duration) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.Expiration = e
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithIdentity(i Identifiable) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.UserID = i.Identity()
|
||||
t.Roles = i.Roles()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithAccessToken(access string) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.AccessToken = access
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithScope(ss ...string) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.Scope = ss
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithAudience(aud string) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.Audience = aud
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithClientID(id uint64) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.ClientID = id
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func applyToIssuer(tm *tokenIssuer, opt ...IssuerOptFn) (err error) {
|
||||
for _, fn := range opt {
|
||||
if err = fn(tm); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
44
server/pkg/auth/token_issuer_test.go
Normal file
44
server/pkg/auth/token_issuer_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIdentityDecoding(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ii = []Identifiable{
|
||||
&identity{id: 1, memberOf: []uint64{}},
|
||||
&identity{id: 2, memberOf: []uint64{2, 3, 4}},
|
||||
}
|
||||
|
||||
tm, err = NewTokenIssuer(WithSecretSigner("test"))
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
|
||||
for _, i := range ii {
|
||||
t.Run(i.String(), func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
token jwt.Token
|
||||
signed []byte
|
||||
)
|
||||
|
||||
signed, err = tm.Sign(WithIdentity(i))
|
||||
req.NoError(err)
|
||||
|
||||
token, err = jwt.Parse(signed)
|
||||
req.NoError(err)
|
||||
|
||||
ift := IdentityFromToken(token)
|
||||
|
||||
req.Equal(i.Identity(), ift.Identity())
|
||||
req.Equal(i.Roles(), ift.Roles())
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
101
server/pkg/auth/token_middleware.go
Normal file
101
server/pkg/auth/token_middleware.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/go-chi/jwtauth"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
)
|
||||
|
||||
var (
|
||||
HttpTokenVerifier func(http.Handler) http.Handler
|
||||
)
|
||||
|
||||
// verifier returns a jwt verification middleware
|
||||
//
|
||||
// Tasks
|
||||
// 1. picks token from header, query or cookie
|
||||
// 2. extracts identity (if any) and adds it into request context
|
||||
//
|
||||
// In there is no token
|
||||
func verifier(ja *jwtauth.JWTAuth) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := jwtauth.VerifyRequest(ja, r, jwtauth.TokenFromHeader, jwtauth.TokenFromQuery, jwtauth.TokenFromCookie)
|
||||
ctx := r.Context()
|
||||
|
||||
if token != nil && err == nil {
|
||||
if err = TokenIssuer.Validate(ctx, token); err != nil {
|
||||
errors.ProperlyServeHTTP(w, r, err, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx = jwtauth.NewContext(ctx, token, err)
|
||||
ctx = SetIdentityToContext(ctx, IdentityFromToken(token))
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TokenVerifierMiddlewareWithSecretSigner returns HTTP handler with simple jwa.HS512 + secret verifier
|
||||
//
|
||||
// This should be 1:1 with token issuer!
|
||||
func TokenVerifierMiddlewareWithSecretSigner(secret string) (_ func(http.Handler) http.Handler, err error) {
|
||||
if len(secret) == 0 {
|
||||
return nil, fmt.Errorf("JWK missing")
|
||||
}
|
||||
|
||||
var key jwk.Key
|
||||
if key, err = jwk.New([]byte(secret)); err != nil {
|
||||
return nil, fmt.Errorf("could not parse JWK: %w", err)
|
||||
}
|
||||
|
||||
return verifier(jwtauth.New(jwa.HS512.String(), key, nil)), nil
|
||||
}
|
||||
|
||||
// HttpTokenValidator checks if there is a token with identity and matching scope claim
|
||||
//
|
||||
// Empty scope defaults to "api"!
|
||||
func HttpTokenValidator(scope ...string) func(http.Handler) http.Handler {
|
||||
if len(scope) == 0 {
|
||||
// ensure that scope is not empty
|
||||
scope = []string{"api"}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := verifyToken(r.Context(), scope...)
|
||||
if err != nil && !errors.Is(err, jwtauth.ErrNoTokenFound) {
|
||||
errors.ProperlyServeHTTP(w, r, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pulls token from context and validates scope & access-token
|
||||
func verifyToken(ctx context.Context, scope ...string) (err error) {
|
||||
var token jwt.Token
|
||||
if token, _, err = jwtauth.FromContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return errUnauthorized()
|
||||
}
|
||||
|
||||
if len(scope) > 0 && !CheckJwtScope(token, scope...) {
|
||||
return errUnauthorizedScope()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user