Skip to content

Commit

Permalink
feat: add client API key authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
DeepDiver1975 committed Feb 26, 2024
1 parent b6a6cb2 commit fb0484f
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 0 deletions.
115 changes: 115 additions & 0 deletions services/proxy/pkg/command/client_api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package command

import (
"fmt"
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/cs3org/reva/v2/pkg/store"
"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config/parser"
"github.com/owncloud/ocis/v2/services/proxy/pkg/logging"
"github.com/owncloud/ocis/v2/services/proxy/pkg/middleware"
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
"github.com/urfave/cli/v2"
microstore "go-micro.dev/v4/store"
)

// ClientAPIKey is the entrypoint for the client api key commands.
func ClientAPIKey(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "generate-client-api-key",
Usage: "generate client API key",
Category: "maintenance",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "claim",
Value: "string",
Usage: "claim to search for the user: userid, username or email",
Required: true,
},
&cli.StringFlag{
Name: "value",
Value: "string",
Usage: "value to search for the user",
Required: true,
},
},
Before: func(_ *cli.Context) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
Action: func(c *cli.Context) error {
logger := logging.Configure(cfg.Service.Name, cfg.Log)

s := store.Create(
store.Store(cfg.ClientAPIKeyStore.Store),
store.TTL(cfg.ClientAPIKeyStore.TTL),
microstore.Nodes(cfg.ClientAPIKeyStore.Nodes...),
microstore.Database("proxy"),
microstore.Table("client_api_keys"),
store.Authentication(cfg.ClientAPIKeyStore.AuthUsername, cfg.ClientAPIKeyStore.AuthPassword),
)
traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name)
if err != nil {
return err
}

gatewaySelector, err := pool.GatewaySelector(
cfg.Reva.Address,
append(
cfg.Reva.GetRevaOptions(),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(traceProvider),
)...)
if err != nil {
return err
}

var userProvider backend.UserBackend
switch cfg.AccountBackend {
case "cs3":
userProvider = backend.NewCS3UserBackend(
backend.WithLogger(logger),
backend.WithRevaGatewaySelector(gatewaySelector),
backend.WithMachineAuthAPIKey(cfg.MachineAuthAPIKey),
backend.WithOIDCissuer(cfg.OIDC.Issuer),
backend.WithServiceAccount(cfg.ServiceAccount),
)
default:
logger.Fatal().Msgf("Invalid accounts backend type '%s'", cfg.AccountBackend)
}

claim := c.String("claim")
value := c.String("value")
user, _, err := userProvider.GetUserByClaims(c.Context, claim, value)
if err != nil {
return err
}

auth := middleware.ClientAPIKeyAuthenticator{
Logger: logger,
UserProvider: userProvider,
SigningKey: cfg.MachineAuthAPIKey,
Store: s,
}
clientAPIKey, clientAPISecret, err := auth.CreateClientAPIKey()
if err != nil {
return err
}
err = auth.SaveKey(user.Id.OpaqueId, clientAPIKey)
if err != nil {
return err
}

fmt.Printf("Client API key created for %s", user.Username)
fmt.Println()
fmt.Printf(" id : %s", clientAPIKey)
fmt.Println()
fmt.Printf(" secret: %s", clientAPISecret)
fmt.Println()

return nil
},
}
}
1 change: 1 addition & 0 deletions services/proxy/pkg/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func GetCommands(cfg *config.Config) cli.Commands {
Server(cfg),

// interaction with this service
ClientAPIKey(cfg),

// infos about this service
Health(cfg),
Expand Down
15 changes: 15 additions & 0 deletions services/proxy/pkg/command/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,15 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config,
Timeout: time.Second * 10,
}

s := store.Create(
store.Store(cfg.ClientAPIKeyStore.Store),
store.TTL(cfg.ClientAPIKeyStore.TTL),
microstore.Nodes(cfg.ClientAPIKeyStore.Nodes...),
microstore.Database("proxy"),
microstore.Table("client_api_keys"),
store.Authentication(cfg.ClientAPIKeyStore.AuthUsername, cfg.ClientAPIKeyStore.AuthPassword),
)

var authenticators []middleware.Authenticator
if cfg.EnableBasicAuth {
logger.Warn().Msg("basic auth enabled, use only for testing or development")
Expand All @@ -348,6 +357,12 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config,
UserProvider: userProvider,
})
}
authenticators = append(authenticators, middleware.ClientAPIKeyAuthenticator{
Logger: logger,
UserProvider: userProvider,
SigningKey: cfg.MachineAuthAPIKey,
Store: s,
})

authenticators = append(authenticators, middleware.PublicShareAuthenticator{
Logger: logger,
Expand Down
12 changes: 12 additions & 0 deletions services/proxy/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Config struct {
RoleAssignment RoleAssignment `yaml:"role_assignment"`
PolicySelector *PolicySelector `yaml:"policy_selector"`
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
ClientAPIKeyStore ClientAPIKeyStore `yaml:"client_api_key_store"`
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here."`
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim."`
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'."`
Expand Down Expand Up @@ -134,6 +135,17 @@ type Cache struct {
AuthPassword string `yaml:"password" env:"OCIS_CACHE_AUTH_PASSWORD;PROXY_OIDC_USERINFO_CACHE_AUTH_PASSWORD" desc:"The password to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured."`
}

// ClientAPIKeyStore is a TTL cache configuration.
type ClientAPIKeyStore struct {
Store string `yaml:"store" env:"OCIS_CACHE_STORE;PROXY_CLIENT_API_KEY_STORE" desc:"The type of the cache store. Supported values are: 'memory', 'redis-sentinel', 'nats-js-kv', 'noop'. See the text description for details."`
Nodes []string `yaml:"addresses" env:"OCIS_CACHE_STORE_NODES;PROXY_CLIENT_API_KEY_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' or 'ocmem' stores are configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details."`
TTL time.Duration `yaml:"ttl" env:"OCIS_CACHE_TTL;PROXY_CLIENT_API_KEY_TTL" desc:"Default time to live for user info in the user info cache. Only applied when access tokens has no expiration. See the Environment Variable Types description for more details."`
Size int `yaml:"size" env:"OCIS_CACHE_SIZE;PROXY_CLIENT_API_KEY_SIZE" desc:"The maximum quantity of items in the user info cache. Only applies when store type 'ocmem' is configured. Defaults to 512 which is derived from the ocmem package though not exclicitely set as default."`
DisablePersistence bool `yaml:"disable_persistence" env:"OCIS_CACHE_DISABLE_PERSISTENCE;PROXY_CLIENT_API_KEY_DISABLE_PERSISTENCE" desc:"Disables persistence of the cache. Only applies when store type 'nats-js-kv' is configured. Defaults to false."`
AuthUsername string `yaml:"username" env:"OCIS_CACHE_AUTH_USERNAME;PROXY_CLIENT_API_KEY_AUTH_USERNAME" desc:"The username to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured."`
AuthPassword string `yaml:"password" env:"OCIS_CACHE_AUTH_PASSWORD;PROXY_CLIENT_API_KEY_AUTH_PASSWORD" desc:"The password to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured."`
}

// RoleAssignment contains the configuration for how to assign roles to users during login
type RoleAssignment struct {
Driver string `yaml:"driver" env:"PROXY_ROLE_ASSIGNMENT_DRIVER" desc:"The mechanism that should be used to assign roles to user upon login. Supported values: 'default' or 'oidc'. 'default' will assign the role 'user' to users which don't have a role assigned at the time they login. 'oidc' will assign the role based on the value of a claim (configured via PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM) from the users OIDC claims."`
Expand Down
136 changes: 136 additions & 0 deletions services/proxy/pkg/middleware/client_api_key_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package middleware

import (
"encoding/json"
"errors"
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/golang-jwt/jwt"
"github.com/google/uuid"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
"go-micro.dev/v4/store"
"net/http"
)

// ClientAPIKeyAuthenticator is the authenticator responsible for client API keys.
type ClientAPIKeyAuthenticator struct {
Logger log.Logger
UserProvider backend.UserBackend
SigningKey string
Store store.Store
}

type ClientAPIKeyRecord struct {
UserId string
Scopes []string
}

func (m ClientAPIKeyAuthenticator) CreateClientAPIKey() (string, string, error) {
k := uuid.New()
signature, err := jwt.SigningMethodHS256.Sign(k.String(), []byte(m.SigningKey))
if err != nil {
return "", "", err
}
return k.String(), signature, nil
}

func (m ClientAPIKeyAuthenticator) SaveKey(userId string, keyid string) error {
record := ClientAPIKeyRecord{
UserId: userId,
}
bytes, err := json.Marshal(record)
if err != nil {
return err
}
r := store.Record{
Key: keyid,
Value: bytes,
}
return m.Store.Write(&r)
}

// Authenticate implements the authenticator interface to authenticate requests via basic auth.
func (m ClientAPIKeyAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if isPublicPath(r.URL.Path) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}

clientApiKeyId, clientApiKeySecret, ok := r.BasicAuth()
if !ok {
return nil, false
}

// re-build JWT
err := m.VerifyBasicAuth(clientApiKeyId, clientApiKeySecret)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "client_api_keys").
Str("path", r.URL.Path).
Msg("failed to authenticate request")
return nil, false
}

// lookup user data in micro store
clientAPIKeyRecord, err := m.LookupUser(clientApiKeyId)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "client_api_keys").
Str("path", r.URL.Path).
Msg("failed to lookup client api key record")
return nil, false
}

_, token, err := m.UserProvider.GetUserByClaims(r.Context(), "userid", clientAPIKeyRecord.UserId)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "client_api_keys").
Str("path", r.URL.Path).
Str("userid", clientAPIKeyRecord.UserId).
Msg("failed to get user by userid")
return nil, false
}

// set token in request
r.Header.Set(revactx.TokenHeader, token)

m.Logger.Debug().
Str("authenticator", "client_api_keys").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}

func (m ClientAPIKeyAuthenticator) VerifyBasicAuth(username string, password string) error {
signature, err := jwt.SigningMethodHS256.Sign(username, []byte(m.SigningKey))
if err != nil {
return err
}
if signature != password {
return errors.New("client api key secret not matching the signature")
}
return nil
}

func (m ClientAPIKeyAuthenticator) LookupUser(clientApiKeyId string) (*ClientAPIKeyRecord, error) {
records, err := m.Store.Read(clientApiKeyId)
if err != nil {
return nil, err
}
if len(records) < 1 {
return nil, store.ErrNotFound
}
data := records[0].Value
v := ClientAPIKeyRecord{}
err = json.Unmarshal(data, &v)
if err != nil {
return nil, err
}

return &v, nil
}
55 changes: 55 additions & 0 deletions services/proxy/pkg/middleware/client_api_key_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package middleware

import (
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"go-micro.dev/v4/store"
"net/http"
"net/http/httptest"

. "github.com/onsi/ginkgo/v2"
"github.com/stretchr/testify/mock"

. "github.com/onsi/gomega"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend/mocks"
)

var _ = Describe("Authenticating requests", Label("ClientAPIKeyAuthenticator"), func() {
var authenticator ClientAPIKeyAuthenticator
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything).Return(
nil,
"reva-token",
nil,
)

BeforeEach(func() {
authenticator = ClientAPIKeyAuthenticator{
Logger: log.NewLogger(),
UserProvider: &ub,
SigningKey: "lorwm",
Store: store.NewMemoryStore(),
}
})

When("the request contains correct data", func() {
It("should successfully authenticate", func() {
// user creates client api key
key, s, err := authenticator.CreateClientAPIKey()
Expect(err).To(BeNil())

err = authenticator.SaveKey("einstein", key)
Expect(err).To(BeNil())

// call api with client api key
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth(key, s)

req2, valid := authenticator.Authenticate(req)

Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
Expect(req2.Header.Get(revactx.TokenHeader)).To(Equal("reva-token"))
})
})
})

0 comments on commit fb0484f

Please sign in to comment.