diff --git a/Makefile b/Makefile index 075afdd..566b7c9 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,6 @@ all: test .PHONY: test test: - cd internal && go test -covermode=atomic cd internal/auth && go test -covermode=atomic cd internal/settings && go test -covermode=atomic cd logger && go test -covermode=atomic @@ -21,13 +20,13 @@ test: .PHONY: test_build test_build: go mod verify && go mod tidy - cd examples/auth/api && go build main.go && rm main - cd examples/auth/cli && go build cli.go && rm cli + cd examples/service && go build main.go && rm main + cd examples/cli && go build cli.go && rm cli cd examples/appengine && go build main.go && rm main .PHONY: examples -examples: example_cli example_api +examples: example_cli example_service .PHONY: example_appengine example_appengine: @@ -35,12 +34,12 @@ example_appengine: .PHONY: example_cli example_cli: - cd examples/auth/cli && go build -o ${EXAMPLE_NAME} cli.go && mv ${EXAMPLE_NAME} ../../../bin/${EXAMPLE_NAME} + cd examples/cli && go build -o ${EXAMPLE_NAME} cli.go && mv ${EXAMPLE_NAME} ../../bin/${EXAMPLE_NAME} chmod +x bin/${EXAMPLE_NAME} -.PHONY: example_api -example_api: - cd examples/auth/cli && go build -o svc cli.go && mv svc ../../../bin/${EXAMPLE_NAME}svc +.PHONY: example_service +example_service: + cd examples/service && go build -o svc main.go && mv svc ../../bin/${EXAMPLE_NAME}svc chmod +x bin/${EXAMPLE_NAME}svc #.PHONY example_api_container diff --git a/api/client.go b/api/client.go index c584e6f..aab0d8b 100644 --- a/api/client.go +++ b/api/client.go @@ -7,8 +7,6 @@ import ( "fmt" "net/http" - "github.com/txsvc/apikit" - "github.com/txsvc/stdlib/v2" "github.com/txsvc/apikit/config" @@ -27,21 +25,24 @@ const ( var ( // ErrMissingCredentials indicates that a credentials are is missing ErrMissingCredentials = errors.New("missing credentials") + + // ErrApiInvocationError indicates an error in an API call + ErrApiInvocationError = errors.New("api invocation error") ) // Client - API client encapsulating the http client type ( Client struct { httpClient *http.Client - cfg *settings.Settings + cfg *settings.DialSettings logger logger.Logger userAgent string trace string } ) -func NewClient(cfg *settings.Settings, logger logger.Logger) (*Client, error) { - var _cfg *settings.Settings +func NewClient(cfg *settings.DialSettings, logger logger.Logger) (*Client, error) { + var cfg_ *settings.DialSettings httpClient, err := NewTransport(logger, http.DefaultTransport) if err != nil { @@ -51,20 +52,20 @@ func NewClient(cfg *settings.Settings, logger logger.Logger) (*Client, error) { // create or clone the settings if cfg != nil { c := cfg.Clone() - _cfg = &c + cfg_ = &c } else { - _cfg = config.GetSettings() - if _cfg.Credentials == nil { - _cfg.Credentials = &settings.Credentials{} // just provide something to prevent NPEs further down + cfg_ = config.GetConfig().Settings() + if cfg_.Credentials == nil { + cfg_.Credentials = &settings.Credentials{} // just provide something to prevent NPEs further down } } return &Client{ httpClient: httpClient, - cfg: _cfg, + cfg: cfg_, logger: logger, - userAgent: config.UserAgentString(), - trace: stdlib.GetString("APIKIT_FORCE_TRACE", ""), + userAgent: config.GetConfig().Info().UserAgentString(), + trace: stdlib.GetString(config.ForceTraceEnv, ""), }, nil } @@ -142,7 +143,7 @@ func (c *Client) roundTrip(req *http.Request, response interface{}) (int, error) } return status.Status, fmt.Errorf(status.Message) } - return resp.StatusCode, apikit.ErrApiError + return resp.StatusCode, ErrApiInvocationError } // unmarshal the response if one is expected diff --git a/api/resource_auth.go b/api/resource_auth.go index 4f5de05..d8e3754 100644 --- a/api/resource_auth.go +++ b/api/resource_auth.go @@ -5,12 +5,13 @@ import ( "net/http" "github.com/labstack/echo/v4" + + "github.com/txsvc/stdlib/v2" + "github.com/txsvc/apikit/config" "github.com/txsvc/apikit/helpers" - "github.com/txsvc/apikit/internal" "github.com/txsvc/apikit/internal/auth" "github.com/txsvc/apikit/internal/settings" - "github.com/txsvc/stdlib/v2" ) const ( @@ -18,6 +19,8 @@ const ( InitRoute = "/auth" LoginRoute = "/auth/:sig/:token" LogoutRoute = "/auth/:sig" + + LoginExpiresAfter = 15 ) func WithAuthEndpoints(e *echo.Echo) *echo.Echo { @@ -33,37 +36,44 @@ func WithAuthEndpoints(e *echo.Echo) *echo.Echo { return e } -func (c *Client) InitCommand(cfg *settings.Settings) error { +func (c *Client) InitCommand(cfg *settings.DialSettings) error { _, err := c.POST(fmt.Sprintf("%s%s", NamespacePrefix, InitRoute), cfg, nil) return err } func InitEndpoint(c echo.Context) error { // get the payload - var cfg *settings.Settings = new(settings.Settings) - if err := c.Bind(cfg); err != nil { + var cfg_ *settings.DialSettings = new(settings.DialSettings) + if err := c.Bind(cfg_); err != nil { return StandardResponse(c, http.StatusBadRequest, nil) } // pre-validate the request - if cfg.Credentials == nil || cfg.APIKey == "" { + if cfg_.Credentials == nil || cfg_.APIKey == "" { return StandardResponse(c, http.StatusBadRequest, nil) } - if cfg.Credentials.ProjectID == "" || cfg.Credentials.UserID == "" { + if cfg_.Credentials.ProjectID == "" || cfg_.Credentials.UserID == "" { return StandardResponse(c, http.StatusBadRequest, nil) } + // create a brand new instance so that the client can't sneak anything in we don't want + cfg := settings.DialSettings{ + Credentials: cfg_.Credentials.Clone(), + DefaultScopes: config.GetConfig().GetScopes(), + } + // prepare the settings for registration - cfg.Credentials.Token = internal.CreateSimpleToken() // ignore anything that was provided - cfg.Credentials.Expires = stdlib.IncT(stdlib.Now(), 15) // FIXME: config, valid for 15min - cfg.Status = -2 // signals init + cfg.Credentials.Token = CreateSimpleToken() // ignore anything that was provided + cfg.Credentials.Expires = stdlib.IncT(stdlib.Now(), LoginExpiresAfter) + cfg.APIKey = cfg_.APIKey + cfg.Status = settings.StateInit // signals init - if err := auth.RegisterAuthorization(cfg); err != nil { + if err := auth.RegisterAuthorization(&cfg); err != nil { return StandardResponse(c, http.StatusBadRequest, nil) // FIXME: or 409/Conflict ? } // all good so far, send the confirmation - err := helpers.MailgunSimpleEmail("ops@txs.vc", cfg.Credentials.UserID, "auth", fmt.Sprintf("the token: %s\n", cfg.Credentials.Token)) + err := helpers.MailgunSimpleEmail("ops@txs.vc", cfg.Credentials.UserID, fmt.Sprintf("your api access credentials (%d)", stdlib.Now()), fmt.Sprintf("the token: %s\n", cfg.Credentials.Token)) if err != nil { return StandardResponse(c, http.StatusBadRequest, nil) } @@ -93,24 +103,29 @@ func LoginEndpoint(c echo.Context) error { } // verify the request - _cfg, err := auth.LookupByToken(token) - if _cfg == nil && err != nil { + cfg_, err := auth.LookupByToken(token) + if cfg_ == nil && err != nil { return ErrorResponse(c, http.StatusBadRequest, ErrInternalError, "token") } - if _cfg == nil && err == nil { + if cfg_ == nil && err == nil { return ErrorResponse(c, http.StatusBadRequest, config.ErrInitializingConfiguration, "not found") // simply not there ... } // compare provided signature with the expected signature - if sig != signature(_cfg.APIKey, _cfg.Credentials.Token) { + if sig != signature(cfg_.APIKey, cfg_.Credentials.Token) { return ErrorResponse(c, http.StatusBadRequest, config.ErrInitializingConfiguration, "invalid sig") } + // check if the token is still valid + if cfg_.Credentials.Expires < stdlib.Now() { + return ErrorResponse(c, http.StatusBadRequest, auth.ErrTokenExpired, "expired") + } + // everything checks out, create/register the real credentials now ... - cfg := _cfg.Clone() // clone, otherwise stupid things happen with pointers ! + cfg := cfg_.Clone() // clone, otherwise stupid things happen with pointers ! cfg.Credentials.Expires = 0 // FIXME: really never ? - cfg.Credentials.Token = internal.CreateSimpleToken() - cfg.Status = 1 // FIXME: LOGGED_IN as const + cfg.Credentials.Token = CreateSimpleToken() + cfg.Status = settings.StateAuthorized // FIXME: what about scopes ? @@ -159,7 +174,7 @@ func LogoutEndpoint(c echo.Context) error { } // update the cache and store - cfg.Status = -1 // just set to invalid and expired + cfg.Status = settings.StateUndefined // just set to invalid and expired cfg.Credentials.Expires = stdlib.Now() - 1 if err := auth.UpdateStore(cfg); err != nil { return ErrorResponse(c, http.StatusBadRequest, err, "update store") @@ -172,3 +187,8 @@ func LogoutEndpoint(c echo.Context) error { func signature(apiKey, token string) string { return stdlib.Fingerprint(fmt.Sprintf("%s%s", apiKey, token)) } + +func CreateSimpleToken() string { + token, _ := stdlib.UUID() + return token +} diff --git a/app_test.go b/app_test.go index 811d063..e4be53f 100644 --- a/app_test.go +++ b/app_test.go @@ -31,7 +31,7 @@ func timeoutShutdown(ctx context.Context, a *App) error { fmt.Println("blocking ...") } - return nil + //return nil } func TestNewSimple(t *testing.T) { diff --git a/cli/cli.go b/cli/cli.go index 1af76dd..057f68a 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "github.com/urfave/cli/v2" @@ -8,6 +9,11 @@ import ( "github.com/txsvc/apikit/config" ) +var ( + // ErrInvalidNumArguments indicates that the number of arguments in a command is not valid + ErrInvalidNumArguments = errors.New("invalid number of arguments") +) + // NoOpCommand is just a placeholder func NoOpCommand(c *cli.Context) error { return cli.Exit(fmt.Sprintf("%s: command '%s' is not implemented", c.App.Name, c.Command.Name), 0) @@ -18,7 +24,7 @@ func WithGlobalFlags() []cli.Flag { &cli.StringFlag{ Name: "config", Usage: "configuration and secrets directory", - DefaultText: config.DefaultConfigLocation(), + DefaultText: config.GetConfig().GetConfigLocation(), Aliases: []string{"c"}, }, } diff --git a/cli/cmd_auth.go b/cli/cmd_auth.go index 01dc903..f03a9ff 100644 --- a/cli/cmd_auth.go +++ b/cli/cmd_auth.go @@ -8,11 +8,9 @@ import ( "github.com/txsvc/stdlib/v2" - "github.com/txsvc/apikit" "github.com/txsvc/apikit/api" "github.com/txsvc/apikit/config" "github.com/txsvc/apikit/helpers" - "github.com/txsvc/apikit/internal" "github.com/txsvc/apikit/internal/auth" "github.com/txsvc/apikit/internal/settings" "github.com/txsvc/apikit/logger" @@ -53,7 +51,7 @@ func WithAuthCommands() []*cli.Command { func InitCommand(c *cli.Context) error { if c.NArg() < 1 || c.NArg() > 2 { - return apikit.ErrInvalidNumArguments + return ErrInvalidNumArguments } userid := "" @@ -73,47 +71,60 @@ func InitCommand(c *cli.Context) error { } // load settings - cfg := config.GetSettings() + cfg := config.GetConfig().Settings() + + // get a client instance + cl, err := api.NewClient(cfg, logger.New()) + if err != nil { + return err // FIXME: better err or just pass on what comes? + } // if a passphrase was provided and the fingerprint(realm,userid,phrase) matches the API key, // then the user is re-initializing an existing account which is allowed. The client just // sends a logout request first before initiating the normal auth sequence. - _apiKey := stdlib.Fingerprint(fmt.Sprintf("%s%s%s", config.Name(), userid, mnemonic)) + _apiKey := stdlib.Fingerprint(fmt.Sprintf("%s%s%s", config.GetConfig().Info().Name(), userid, mnemonic)) - if cfg.Status != -2 { - if cfg.APIKey == _apiKey { - // FIXME: send logout - fmt.Println("logout") - } else { - if cfg.Credentials.Token != "" { - return auth.ErrAlreadyInitialized // FIXME: can we do better ? + switch cfg.Status { + case -1: + // set to INVALID + return config.ErrInvalidConfiguration + case 1: + if _apiKey == cfg.APIKey { + // correct pass phrase was provided, reset the authentication + if err := cl.LogoutCommand(); err != nil { + return err // FIXME: better err or just pass on what comes? } + } else { + // already authenticated, abort + return auth.ErrAlreadyAuthorized } } + // 0, -2: don't care, can be overwritten as the client is not authorized yet + cfg.Credentials = &settings.Credentials{ - ProjectID: config.Name(), + ProjectID: config.GetConfig().Info().Name(), UserID: userid, - Token: internal.CreateSimpleToken(), + Token: api.CreateSimpleToken(), Expires: 0, // FIXME: should this expire after some time? } - cfg.Status = -2 + cfg.Status = settings.StateInit cfg.APIKey = _apiKey + cfg.Scopes = make([]string, 0) + cfg.DefaultScopes = make([]string, 0) + cfg.Options = make(map[string]string) // now start the auth init process with the API - cl, err := api.NewClient(cfg, logger.New()) - if err != nil { - return err // FIXME: better err or just pass on what comes? - } + err = cl.InitCommand(cfg) if err != nil { return err // FIXME: better err or just pass on what comes? } // finally save the file - pathToFile := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigFileName) - if err := cfg.WriteToFile(pathToFile); err != nil { + pathToFile := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigName) + if err := helpers.WriteDialSettings(cfg, pathToFile); err != nil { return config.ErrInitializingConfiguration } @@ -128,13 +139,13 @@ func InitCommand(c *cli.Context) error { func LoginCommand(c *cli.Context) error { if c.NArg() < 1 || c.NArg() > 1 { - return apikit.ErrInvalidNumArguments + return ErrInvalidNumArguments } token := c.Args().First() // load settings - cfg := config.GetSettings() + cfg := config.GetConfig().Settings() if !cfg.Credentials.IsValid() { return config.ErrInvalidConfiguration } @@ -151,13 +162,13 @@ func LoginCommand(c *cli.Context) error { // update the local config cfg.Credentials.Token = status.Message - cfg.Status = 1 // LOGGED_IN + cfg.Status = settings.StateAuthorized // LOGGED_IN if !cfg.Credentials.IsValid() { return config.ErrInvalidConfiguration } - pathToFile := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigFileName) - if err := cfg.WriteToFile(pathToFile); err != nil { + pathToFile := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigName) + if err := helpers.WriteDialSettings(cfg, pathToFile); err != nil { return config.ErrInitializingConfiguration } @@ -168,11 +179,11 @@ func LoginCommand(c *cli.Context) error { func LogoutCommand(c *cli.Context) error { if c.NArg() > 0 { - return apikit.ErrInvalidNumArguments + return ErrInvalidNumArguments } // load settings - cfg := config.GetSettings() + cfg := config.GetConfig().Settings() if !cfg.Credentials.IsValid() { return config.ErrInvalidConfiguration } @@ -189,10 +200,10 @@ func LogoutCommand(c *cli.Context) error { // update the local config cfg.Credentials.Expires = stdlib.Now() - 1 - cfg.Status = -1 // LOGGED_OUT + cfg.Status = settings.StateUndefined // LOGGED_OUT - pathToFile := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigFileName) - if err := cfg.WriteToFile(pathToFile); err != nil { + pathToFile := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigName) + if err := helpers.WriteDialSettings(cfg, pathToFile); err != nil { return config.ErrInitializingConfiguration } diff --git a/config/config.go b/config/config.go index ec203b8..ac49b9f 100644 --- a/config/config.go +++ b/config/config.go @@ -2,7 +2,6 @@ package config import ( "errors" - "fmt" "log" "os" "path/filepath" @@ -12,34 +11,49 @@ import ( ) const ( - DefaultConfigDirLocation = "./.config" - DefaultConfigFileName = "config" - + // runtime settings + PortEnv = "PORT" + APIEndpointENV = "API_ENDPOINT" + // client settings + ForceTraceEnv = "APIKIT_FORCE_TRACE" + // config settings ConfigDirLocationENV = "CONFIG_LOCATION" - APIEndpointENV = "API_ENDPOINT" + + DefaultConfigName = "config" + DefaultConfigLocation = "./.config" + DefaultEndpoint = "http://localhost:8080" // only really useful for testing ... ) type ( - ConfigProviderFunc func() interface{} + // Info holds static information about a service or API + Info struct { + // name: the service's name in human-usable form + name string + // shortName: the abreviated version of the service's name + shortName string + // copyright: info on the copyright/owner of the service/api + copyright string + // about: a short description of the service/api + about string + // majorVersion: the major version of the service/api + majorVersion int + // minorVersion: the minor version of the service/api + minorVersion int + // fixVersion: the fix/patch version of the service/api + fixVersion int + } Configurator interface { - Name() string // name of the project / the real etc - ShortName() string // abreviated name, used for e.g. the cli tool - Copyright() string - About() string - - MajorVersion() int - MinorVersion() int - FixVersion() int - - DefaultConfigLocation() string // default: ./.config - - GetConfigLocation() string // same as DefaultConfigLocation() unless explicitly set + // AppInfo returns static information about the app or service + Info() *Info + // GetScopes returns the user-provided scopes, if set, or else falls back to the default scopes. + GetScopes() []string + // GetConfigLocation returns the path to the config location, if set, or the default location otherwise. + GetConfigLocation() string // './.config' unless explicitly set. + // SetConfigLocation explicitly sets the location where the configuration is expected. The location's existence is NOT verified. SetConfigLocation(string) - - // client & endpoint settings and credentials - GetDefaultSettings() *settings.Settings - GetSettings() *settings.Settings + // Settings returns the app settings, if configured, or falls back to a default, minimal configuration + Settings() *settings.DialSettings } ) @@ -51,126 +65,41 @@ var ( // ErrInvalidConfiguration indicates that parameters used to configure the service were invalid ErrInvalidConfiguration = errors.New("invalid configuration") - confProvider interface{} + // the config "singleton" + config_ interface{} ) func init() { // makes sure that SOMETHING is initialized - InitConfigProvider(NewSimpleConfigProvider()) -} - -func InitConfigProvider(provider interface{}) { - confProvider = provider -} - -func VersionString() string { - return fmt.Sprintf("%d.%d.%d", MajorVersion(), MinorVersion(), FixVersion()) -} - -func UserAgentString() string { - return fmt.Sprintf("%s %d.%d.%d", ShortName(), MajorVersion(), MinorVersion(), FixVersion()) -} - -func ServerString() string { - return fmt.Sprintf("%s %d.%d.%d", ShortName(), MajorVersion(), MinorVersion(), FixVersion()) -} - -func Name() string { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).Name() -} - -func ShortName() string { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).ShortName() -} - -func Copyright() string { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).Copyright() + SetProvider(NewLocalConfigProvider()) } -func About() string { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).About() +func SetProvider(provider interface{}) { + config_ = provider } -func MajorVersion() int { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).MajorVersion() -} - -func MinorVersion() int { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).MinorVersion() -} -func FixVersion() int { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).FixVersion() -} - -// DefaultConfigLocation returns a default location e.g. %HOME/.config -func DefaultConfigLocation() string { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).DefaultConfigLocation() -} - -// ConfigLocation returns the actual location or DefaultConfigLocation() if undefined -func GetConfigLocation() string { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).GetConfigLocation() +func GetConfig() Configurator { + return config_.(Configurator) } // SetConfigLocation sets the actual location without checking if the location actually exists ! func SetConfigLocation(loc string) { - if confProvider == nil { + if config_ == nil { log.Fatal(ErrMissingConfigurator) } - confProvider.(Configurator).SetConfigLocation(loc) + config_.(Configurator).SetConfigLocation(loc) } // ResolveConfigLocation returns the full path to the config location func ResolveConfigLocation() string { - cl := GetConfigLocation() + cl := GetConfig().GetConfigLocation() if strings.HasPrefix(cl, ".") { // relative to working dir wd, err := os.Getwd() if err != nil { - return DefaultConfigLocation() + return GetConfig().GetConfigLocation() } return filepath.Join(wd, cl) } - return GetConfigLocation() -} - -func GetDefaultSettings() *settings.Settings { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).GetDefaultSettings() -} - -func GetSettings() *settings.Settings { - if confProvider == nil { - log.Fatal(ErrMissingConfigurator) - } - return confProvider.(Configurator).GetSettings() + return cl } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..7c5a771 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInitConfig(t *testing.T) { + conf1 := GetConfig() + assert.NotNil(t, conf1) + + conf2 := NewLocalConfigProvider().(*localConfig) + assert.NotNil(t, conf2) +} + +func TestSetConfig(t *testing.T) { + conf := NewLocalConfigProvider().(*localConfig) + assert.NotNil(t, conf) + + SetProvider(conf) + assert.Equal(t, conf, GetConfig()) +} + +func TestResolveConfigLocation(t *testing.T) { + SetProvider(NewLocalConfigProvider()) + + cfg := GetConfig() + assert.Equal(t, cfg, GetConfig()) + + path := ResolveConfigLocation() + assert.NotEmpty(t, path) + assert.NotEqual(t, DefaultConfigLocation, path) + + cfg.SetConfigLocation("$HOME/.config") + path = ResolveConfigLocation() + assert.NotEmpty(t, path) + assert.Equal(t, "$HOME/.config", path) +} diff --git a/config/info.go b/config/info.go new file mode 100644 index 0000000..67d43d4 --- /dev/null +++ b/config/info.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" +) + +func NewAppInfo(name, shortName, copyright, about string, major, minor, fix int) Info { + return Info{ + name: name, + shortName: shortName, + copyright: copyright, + about: about, + majorVersion: major, + minorVersion: minor, + fixVersion: fix, + } +} + +func (i *Info) Name() string { + return i.name +} + +func (i *Info) ShortName() string { + return i.shortName +} + +func (i *Info) Copyright() string { + return i.copyright +} + +func (i *Info) About() string { + return i.about +} + +func (i *Info) MajorVersion() int { + return i.majorVersion +} + +func (i *Info) MinorVersion() int { + return i.minorVersion +} + +func (i *Info) FixVersion() int { + return i.fixVersion +} + +func (i *Info) VersionString() string { + return fmt.Sprintf("%d.%d.%d", i.majorVersion, i.minorVersion, i.fixVersion) +} + +func (i *Info) UserAgentString() string { + return fmt.Sprintf("%s %d.%d.%d", i.shortName, i.majorVersion, i.minorVersion, i.fixVersion) +} + +func (i *Info) ServerString() string { + return fmt.Sprintf("%s %d.%d.%d", i.shortName, i.majorVersion, i.minorVersion, i.fixVersion) +} diff --git a/config/info_test.go b/config/info_test.go new file mode 100644 index 0000000..4b09413 --- /dev/null +++ b/config/info_test.go @@ -0,0 +1,24 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVersionStrings(t *testing.T) { + conf := NewLocalConfigProvider().(*localConfig) + assert.NotNil(t, conf) + + info := conf.Info() + assert.NotNil(t, info) + assert.NotEmpty(t, info) + + assert.Equal(t, majorVersion, info.MajorVersion()) + assert.Equal(t, minorVersion, info.MinorVersion()) + assert.Equal(t, fixVersion, info.FixVersion()) + assert.NotEmpty(t, info.Name()) + assert.NotEmpty(t, info.ShortName()) + assert.NotEmpty(t, info.Copyright()) + assert.NotEmpty(t, info.About()) +} diff --git a/config/local.go b/config/local.go index 7d557a6..30722fc 100644 --- a/config/local.go +++ b/config/local.go @@ -7,16 +7,28 @@ import ( "github.com/txsvc/stdlib/v2" - "github.com/txsvc/apikit/internal" + "github.com/txsvc/apikit/helpers" "github.com/txsvc/apikit/internal/auth" "github.com/txsvc/apikit/internal/settings" ) +// the below version numbers should match the git release tags, +// i.e. there should be a version 'v0.1.0' on branch main ! +const ( + majorVersion = 0 + minorVersion = 1 + fixVersion = 0 +) + type ( localConfig struct { - rootDir string // the current working dir - confDir string // the fully qualified path to the conf dir - settings *settings.Settings + // app info + info *Info + // path to configuration settings + rootDir string // the current working dir + confDir string // the fully qualified path to the conf dir + // cached settings + cfg_ *settings.DialSettings } ) @@ -36,41 +48,29 @@ func NewLocalConfigProvider() interface{} { c := &localConfig{ rootDir: dir, confDir: "", + info: &Info{ + name: "appkit", + shortName: "appkit", + copyright: "Copyright 2022, transformative.services, https://txs.vc", + about: "about appkit", + majorVersion: majorVersion, + minorVersion: minorVersion, + fixVersion: fixVersion, + }, } return c } -func (c *localConfig) Name() string { - return "simplecli" -} - -func (c *localConfig) ShortName() string { - return "sc" -} - -func (c *localConfig) Copyright() string { - return "Copyright 2022, transformative.services, https://txs.vc" -} - -func (c *localConfig) About() string { - return "a simple cli (sc) example" -} - -func (c *localConfig) MajorVersion() int { - return majorVersion -} - -func (c *localConfig) MinorVersion() int { - return minorVersion +func (c *localConfig) Info() *Info { + return c.info } -func (c *localConfig) FixVersion() int { - return fixVersion -} - -func (c *localConfig) DefaultConfigLocation() string { - return DefaultConfigDirLocation +func (c *localConfig) GetScopes() []string { + if c.cfg_ != nil { + return c.cfg_.GetScopes() + } + return defaultScopes() } // GetConfigLocation returns the config location that was set using SetConfigLocation(). @@ -78,45 +78,53 @@ func (c *localConfig) DefaultConfigLocation() string { // returns DefaultConfigLocation() if no environment variable was set. func (c *localConfig) GetConfigLocation() string { if len(c.confDir) == 0 { - return stdlib.GetString(ConfigDirLocationENV, c.DefaultConfigLocation()) + return stdlib.GetString(ConfigDirLocationENV, DefaultConfigLocation) } return c.confDir } func (c *localConfig) SetConfigLocation(loc string) { c.confDir = loc -} - -func (c *localConfig) GetDefaultSettings() *settings.Settings { - return &settings.Settings{ - Endpoint: "http://localhost:8080", - DefaultScopes: []string{ - auth.ScopeApiRead, - auth.ScopeApiWrite, - }, - Credentials: &settings.Credentials{}, // add this to avoid NPEs further down + if c.cfg_ != nil { + c.cfg_ = nil // force a reload the next time GetSettings() is called ... } } -func (c *localConfig) GetSettings() *settings.Settings { - if c.settings != nil { - return c.settings +func (c *localConfig) Settings() *settings.DialSettings { + if c.cfg_ != nil { + return c.cfg_ } // try to load the dial settings - pathToFile := filepath.Join(ResolveConfigLocation(), DefaultConfigFileName) - cs, err := internal.ReadSettingsFromFile(pathToFile) // FIXME: internal. will become an issue later + pathToFile := filepath.Join(ResolveConfigLocation(), DefaultConfigName) + cfg, err := helpers.ReadDialSettings(pathToFile) if err != nil { - cs = GetDefaultSettings() + cfg = c.defaultSettings() // save to the default location - if err = cs.WriteToFile(pathToFile); err != nil { + if err = helpers.WriteDialSettings(cfg, pathToFile); err != nil { log.Fatal(err) } } - // patch values from ENV if available + // patch values from ENV, if available + cfg.Endpoint = stdlib.GetString(APIEndpointENV, cfg.Endpoint) - // make it available for further call - c.settings = cs - return cs + // make it available for future calls + c.cfg_ = cfg + return c.cfg_ +} + +func (c *localConfig) defaultSettings() *settings.DialSettings { + return &settings.DialSettings{ + Endpoint: DefaultEndpoint, + DefaultScopes: defaultScopes(), + Credentials: &settings.Credentials{}, // add this to avoid NPEs further down + } +} + +func defaultScopes() []string { + // FIXME: this gives basic read access to the API. Is this what we want? + return []string{ + auth.ScopeApiRead, + } } diff --git a/config/local_test.go b/config/local_test.go new file mode 100644 index 0000000..a3157ae --- /dev/null +++ b/config/local_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInitLocalProvider(t *testing.T) { + SetProvider(NewLocalConfigProvider()) + + cfg := GetConfig() + assert.NotNil(t, cfg) + + assert.NotNil(t, cfg.Info()) + assert.NotNil(t, cfg.Settings()) + assert.NotEmpty(t, cfg.GetScopes()) +} + +func TestConfigLocation(t *testing.T) { + SetProvider(NewLocalConfigProvider()) + + cfg := GetConfig() + assert.Equal(t, cfg, GetConfig()) + + path := cfg.GetConfigLocation() + assert.NotEmpty(t, path) + assert.Equal(t, DefaultConfigLocation, path) + + cfg.SetConfigLocation("$HOME/.config") + assert.Equal(t, "$HOME/.config", cfg.GetConfigLocation()) +} + +func TestGetSettings(t *testing.T) { + conf := NewLocalConfigProvider().(*localConfig) + assert.NotNil(t, conf) + + SetProvider(conf) + ds := GetConfig().Settings() + assert.NotNil(t, ds) + assert.NotEmpty(t, ds) +} diff --git a/config/simple.go b/config/simple.go deleted file mode 100644 index f6777cb..0000000 --- a/config/simple.go +++ /dev/null @@ -1,84 +0,0 @@ -package config - -import ( - "github.com/txsvc/stdlib/v2" - - "github.com/txsvc/apikit/internal/auth" - "github.com/txsvc/apikit/internal/settings" -) - -type ( - simpleConfig struct { - root string // the fully qualified path to the conf dir - } -) - -var ( - // interface guard to ensure that all required functions are implemented - _ Configurator = (*simpleConfig)(nil) -) - -func NewSimpleConfigProvider() interface{} { - return &simpleConfig{} -} - -func (c *simpleConfig) Name() string { - return "appkit" -} - -func (c *simpleConfig) ShortName() string { - return "appkit" -} - -func (c *simpleConfig) Copyright() string { - return "copyright 2022, transformative.services, https://txs.vc" -} - -func (c *simpleConfig) About() string { - return "about appkit" -} - -func (c *simpleConfig) MajorVersion() int { - return majorVersion -} - -func (c *simpleConfig) MinorVersion() int { - return minorVersion -} - -func (c *simpleConfig) FixVersion() int { - return fixVersion -} - -func (c *simpleConfig) DefaultConfigLocation() string { - return DefaultConfigDirLocation -} - -// GetConfigLocation returns the config location that was set using SetConfigLocation(). -// If no location is defined, GetConfigLocation looks for ENV['CONFIG_LOCATION'] or -// returns DefaultConfigLocation() if no environment variable was set. -func (c *simpleConfig) GetConfigLocation() string { - if len(c.root) == 0 { - return stdlib.GetString(ConfigDirLocationENV, c.DefaultConfigLocation()) - } - return c.root -} - -func (c *simpleConfig) SetConfigLocation(loc string) { - c.root = loc -} - -func (c *simpleConfig) GetDefaultSettings() *settings.Settings { - return &settings.Settings{ - Endpoint: "http://localhost:8080", - DefaultScopes: []string{ - auth.ScopeApiRead, - auth.ScopeApiWrite, - }, - Credentials: &settings.Credentials{}, // add this to avoid NPEs further down - } -} - -func (c *simpleConfig) GetSettings() *settings.Settings { - return c.GetDefaultSettings() -} diff --git a/config/simple_test.go b/config/simple_test.go deleted file mode 100644 index dec09d1..0000000 --- a/config/simple_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package config - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestVersionStrings(t *testing.T) { - assert.NotEmpty(t, VersionString()) - assert.NotEmpty(t, UserAgentString()) - assert.NotEmpty(t, ServerString()) -} - -func TestGenericConfig(t *testing.T) { - conf := NewSimpleConfigProvider().(*simpleConfig) - assert.NotNil(t, conf) - - assert.Equal(t, conf.Name(), Name()) - assert.Equal(t, conf.ShortName(), ShortName()) - assert.Equal(t, conf.Copyright(), Copyright()) - assert.Equal(t, conf.About(), About()) - - assert.Equal(t, conf.MajorVersion(), MajorVersion()) - assert.Equal(t, conf.MinorVersion(), MinorVersion()) - assert.Equal(t, conf.FixVersion(), FixVersion()) - - assert.Equal(t, conf.DefaultConfigLocation(), DefaultConfigLocation()) - assert.Equal(t, conf.GetConfigLocation(), GetConfigLocation()) - assert.Equal(t, conf.GetConfigLocation(), conf.DefaultConfigLocation()) -} - -func TestSetConfigLocation(t *testing.T) { - conf := NewSimpleConfigProvider().(*simpleConfig) - assert.NotNil(t, conf) - - InitConfigProvider(conf) - - assert.Equal(t, DefaultConfigDirLocation, DefaultConfigLocation()) - assert.Equal(t, DefaultConfigDirLocation, GetConfigLocation()) - assert.Equal(t, conf.DefaultConfigLocation(), conf.GetConfigLocation()) - - conf.SetConfigLocation("$HOME/.config") - - assert.Equal(t, "$HOME/.config", GetConfigLocation()) - assert.Equal(t, DefaultConfigDirLocation, DefaultConfigLocation()) -} - -func TestGetDefaultSettings(t *testing.T) { - conf := NewSimpleConfigProvider().(*simpleConfig) - assert.NotNil(t, conf) - - InitConfigProvider(conf) - ds := GetDefaultSettings() - - assert.NotNil(t, ds) - assert.NotEmpty(t, ds) -} - -func TestGetSettings(t *testing.T) { - conf := NewSimpleConfigProvider().(*simpleConfig) - assert.NotNil(t, conf) - - InitConfigProvider(conf) - ds := GetSettings() - - assert.NotNil(t, ds) - assert.NotEmpty(t, ds) -} diff --git a/config/version.go b/config/version.go deleted file mode 100644 index 96b0228..0000000 --- a/config/version.go +++ /dev/null @@ -1,10 +0,0 @@ -package config - -const ( - // MajorVersion of the API - majorVersion = 0 - // MinorVersion of the API - minorVersion = 1 - // FixVersion of the API - fixVersion = 0 -) diff --git a/errordef.go b/errordef.go deleted file mode 100644 index c480375..0000000 --- a/errordef.go +++ /dev/null @@ -1,21 +0,0 @@ -package apikit - -import "errors" - -var ( - // ErrApiError indicates an error in an API call - ErrApiError = errors.New("api error") - - // ErrInvalidResourceName indicates that the resource name is invalid - ErrInvalidResourceName = errors.New("invalid resource name") - // ErrMissingResourceName indicates that a resource type is missing - ErrMissingResourceName = errors.New("missing resource type") - // ErrResourceNotFound indicates that the resource does not exist - ErrResourceNotFound = errors.New("resource does not exist") - // ErrResourceExists indicates that the resource does not exist - ErrResourceExists = errors.New("resource already exists") - // ErrInvalidParameters indicates that parameters used in an API call are not valid - ErrInvalidParameters = errors.New("invalid parameters") - // ErrInvalidNumArguments indicates that the number of arguments in an API call is not valid - ErrInvalidNumArguments = errors.New("invalid arguments") -) diff --git a/examples/appengine/config.go b/examples/appengine/config.go new file mode 100644 index 0000000..a8627cc --- /dev/null +++ b/examples/appengine/config.go @@ -0,0 +1,72 @@ +package main + +import ( + "github.com/txsvc/stdlib/v2" + + "github.com/txsvc/apikit/config" + "github.com/txsvc/apikit/internal/auth" + "github.com/txsvc/apikit/internal/settings" +) + +// FIXME: make this Google AppEngine specific ! + +var ( + // interface guard to ensure that all required functions are implemented + _ config.Configurator = (*appConfig)(nil) +) + +func (c *appConfig) Info() *config.Info { + return c.info +} + +func (c *appConfig) GetScopes() []string { + if c.cfg_ != nil { + return c.cfg_.GetScopes() + } + return defaultScopes() +} + +// GetConfigLocation returns the config location that was set using SetConfigLocation(). +// If no location is defined, GetConfigLocation looks for ENV['CONFIG_LOCATION'] or +// returns DefaultConfigLocation() if no environment variable was set. +func (c *appConfig) GetConfigLocation() string { + if len(c.root) == 0 { + return stdlib.GetString(config.ConfigDirLocationENV, config.DefaultConfigLocation) + } + return c.root +} + +func (c *appConfig) SetConfigLocation(loc string) { + c.root = loc + if c.cfg_ != nil { + c.cfg_ = nil // force a reload the next time GetSettings() is called ... + } +} + +func (c *appConfig) Settings() *settings.DialSettings { + if c.cfg_ != nil { + return c.cfg_ + } + // make it available for future calls + c.cfg_ = c.defaultSettings() + return c.cfg_ +} + +func (c *appConfig) defaultSettings() *settings.DialSettings { + cfg := settings.DialSettings{ + Endpoint: config.DefaultEndpoint, + DefaultScopes: defaultScopes(), + Credentials: &settings.Credentials{}, // add this to avoid NPEs further down + } + // patch values from ENV, if available + cfg.Endpoint = stdlib.GetString(config.APIEndpointENV, cfg.Endpoint) + return &cfg +} + +func defaultScopes() []string { + // FIXME: this gives basic read access to the API. Is this what we want? + return []string{ + auth.ScopeApiRead, + auth.ScopeApiWrite, + } +} diff --git a/examples/appengine/main.go b/examples/appengine/main.go index 757c7fe..4f22f72 100644 --- a/examples/appengine/main.go +++ b/examples/appengine/main.go @@ -14,37 +14,45 @@ import ( "github.com/txsvc/apikit" "github.com/txsvc/apikit/api" "github.com/txsvc/apikit/config" - "github.com/txsvc/apikit/internal" + "github.com/txsvc/apikit/helpers" "github.com/txsvc/apikit/internal/auth" + "github.com/txsvc/apikit/internal/settings" ) -// FIXME: implement in memory certstore +// the below version numbers should match the git release tags, +// i.e. there should be e.g. a version 'v0.1.0' on branch main ! +const ( + // MajorVersion of the API + majorVersion = 0 + // MinorVersion of the API + minorVersion = 1 + // FixVersion of the API + fixVersion = 0 +) + +type ( + appConfig struct { + root string // the fully qualified path to the conf dir + info *config.Info + // cached settings + cfg_ *settings.DialSettings + } +) func init() { // initialize the config provider - config.InitConfigProvider(config.NewSimpleConfigProvider()) + config.SetProvider(NewAppEngineConfigProvider()) // create a default configuration for the service (if none exists) - path := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigFileName) + path := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigName) if _, err := os.Stat(path); os.IsNotExist(err) { os.MkdirAll(filepath.Dir(path), os.ModePerm) - // create credentials and keys - cfg, err := internal.InitSettings(config.Name(), config.Name()) - if err != nil { - log.Fatal(err) - } - - // defaults from this config provider - def := config.GetDefaultSettings() - - // copy the credentials and api keys - def.Credentials = cfg.Credentials - def.APIKey = cfg.APIKey - def.Scopes = append(def.Scopes, auth.ScopeApiAdmin) + // create credentials and keys with defaults from this config provider + cfg := config.GetConfig().Settings() // save the new configuration - def.WriteToFile(path) + helpers.WriteDialSettings(cfg, path) } // initialize the credentials store @@ -96,8 +104,24 @@ func pingEndpoint(c echo.Context) error { resp := api.StatusObject{ Status: http.StatusOK, - Message: fmt.Sprintf("version: %s", config.VersionString()), + Message: fmt.Sprintf("version: %s", config.GetConfig().Info().VersionString()), } return api.StandardResponse(c, http.StatusOK, resp) } + +func NewAppEngineConfigProvider() interface{} { + info := config.NewAppInfo( + "appengine kit", + "aek", + "Copyright 2022, transformative.services, https://txs.vc", + "about appengine kit", + majorVersion, + minorVersion, + fixVersion, + ) + + return &appConfig{ + info: &info, + } +} diff --git a/examples/auth/cli/cli.go b/examples/cli/cli.go similarity index 88% rename from examples/auth/cli/cli.go rename to examples/cli/cli.go index a080fc4..9ee6436 100644 --- a/examples/auth/cli/cli.go +++ b/examples/cli/cli.go @@ -14,16 +14,17 @@ import ( ) func init() { - config.InitConfigProvider(config.NewLocalConfigProvider()) + config.SetProvider(config.NewLocalConfigProvider()) } func main() { // initialize the CLI + cfg := config.GetConfig() app := &cli.App{ - Name: config.ShortName(), - Version: config.VersionString(), - Usage: config.About(), - Copyright: config.Copyright(), + Name: cfg.Info().ShortName(), + Version: cfg.Info().VersionString(), + Usage: cfg.Info().About(), + Copyright: cfg.Info().Copyright(), Commands: setupCommands(), Flags: setupFlags(), Before: func(c *cli.Context) error { diff --git a/examples/auth/api/Dockerfile b/examples/service/Dockerfile similarity index 100% rename from examples/auth/api/Dockerfile rename to examples/service/Dockerfile diff --git a/examples/auth/api/main.go b/examples/service/main.go similarity index 78% rename from examples/auth/api/main.go rename to examples/service/main.go index e12b889..a21cee7 100644 --- a/examples/auth/api/main.go +++ b/examples/service/main.go @@ -15,35 +15,24 @@ import ( "github.com/txsvc/apikit" "github.com/txsvc/apikit/api" "github.com/txsvc/apikit/config" - "github.com/txsvc/apikit/internal" + "github.com/txsvc/apikit/helpers" "github.com/txsvc/apikit/internal/auth" ) func init() { // initialize the config provider - config.InitConfigProvider(config.NewLocalConfigProvider()) + config.SetProvider(config.NewLocalConfigProvider()) // create a default configuration for the service (if none exists) - path := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigFileName) + path := filepath.Join(config.ResolveConfigLocation(), config.DefaultConfigName) if _, err := os.Stat(path); os.IsNotExist(err) { os.MkdirAll(filepath.Dir(path), os.ModePerm) - // create credentials and keys - cfg, err := internal.InitSettings(config.Name(), config.Name()) - if err != nil { - log.Fatal(err) - } - - // defaults from this config provider - def := config.GetDefaultSettings() - - // copy the credentials and api keys - def.Credentials = cfg.Credentials - def.APIKey = cfg.APIKey - def.Scopes = append(def.Scopes, auth.ScopeApiAdmin) + // create credentials and keys with defaults from this config provider + cfg := config.GetConfig().Settings() // save the new configuration - def.WriteToFile(path) + helpers.WriteDialSettings(cfg, path) } // initialize the credentials store @@ -105,7 +94,7 @@ func pingEndpoint(c echo.Context) error { resp := api.StatusObject{ Status: http.StatusOK, - Message: fmt.Sprintf("version: %s", config.VersionString()), + Message: fmt.Sprintf("version: %s", config.GetConfig().Info().VersionString()), } return api.StandardResponse(c, http.StatusOK, resp) diff --git a/examples/auth/api/run b/examples/service/run similarity index 100% rename from examples/auth/api/run rename to examples/service/run diff --git a/helpers/mnemonic.go b/helpers/mnemonic.go index 88f1675..22636b1 100644 --- a/helpers/mnemonic.go +++ b/helpers/mnemonic.go @@ -1,5 +1,8 @@ package helpers +// THIS HAS NOTHING TODO WITH ANY CRYPTO BS, +// it's only that there is a usable pass-phrase/mnemonic implementation there ! + import ( "errors" "strings" @@ -12,8 +15,10 @@ const ( MinWordsInPassPhrase = 11 ) -// ErrInvalidPassPhrase indicates that the pass phrase is too short -var ErrInvalidPassPhrase = errors.New("invalid pass phrase") +var ( + // ErrInvalidPassPhrase indicates that the pass phrase is too short + ErrInvalidPassPhrase = errors.New("invalid pass phrase") +) func CreateMnemonic(phrase string) (string, error) { mnemonicPhrase := "" diff --git a/helpers/settings.go b/helpers/settings.go new file mode 100644 index 0000000..11c8eb3 --- /dev/null +++ b/helpers/settings.go @@ -0,0 +1,65 @@ +package helpers + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + + "github.com/txsvc/apikit/internal/settings" +) + +const ( + indentChar = " " + filePerm fs.FileMode = 0644 +) + +func ReadDialSettings(path string) (*settings.DialSettings, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + ds := settings.DialSettings{} + if err := json.Unmarshal([]byte(data), &ds); err != nil { + return nil, err + } + return &ds, nil +} + +func WriteDialSettings(cfg *settings.DialSettings, path string) error { + buf, err := json.MarshalIndent(cfg, "", indentChar) + if err != nil { + return err + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + os.MkdirAll(filepath.Dir(path), os.ModePerm) + } + + return os.WriteFile(path, buf, filePerm) +} + +func ReadCredentials(path string) (*settings.Credentials, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cred := settings.Credentials{} + if err := json.Unmarshal([]byte(data), &cred); err != nil { + return nil, err + } + return &cred, nil +} + +func WriteCredentials(cred *settings.Credentials, path string) error { + buf, err := json.MarshalIndent(cred, "", indentChar) + if err != nil { + return err + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + os.MkdirAll(filepath.Dir(path), os.ModePerm) + } + + return os.WriteFile(path, buf, filePerm) +} diff --git a/internal/util_test.go b/helpers/settings_test.go similarity index 77% rename from internal/util_test.go rename to helpers/settings_test.go index 6936c5e..19d56bd 100644 --- a/internal/util_test.go +++ b/helpers/settings_test.go @@ -1,18 +1,17 @@ -package internal +package helpers import ( "os" "testing" "github.com/stretchr/testify/assert" - "github.com/txsvc/apikit/internal/settings" ) const testCredentialFile = "test.json" func TestWriteReadSettings(t *testing.T) { - settings1 := &settings.Settings{ + settings1 := &settings.DialSettings{ Endpoint: "x", //DefaultEndpoint: "X", Scopes: []string{"a", "b"}, @@ -23,10 +22,10 @@ func TestWriteReadSettings(t *testing.T) { settings1.SetOption("FOO", "x") settings1.SetOption("BAR", "x") - err := settings1.WriteToFile(testCredentialFile) + err := WriteDialSettings(settings1, testCredentialFile) assert.NoError(t, err) - settings2, err := ReadSettingsFromFile(testCredentialFile) + settings2, err := ReadDialSettings(testCredentialFile) assert.NoError(t, err) assert.NotEmpty(t, settings2) assert.Equal(t, settings1, settings2) @@ -43,10 +42,10 @@ func TestWriteReadCredentials(t *testing.T) { Expires: 42, } - err := cred1.WriteToFile(testCredentialFile) + err := WriteCredentials(cred1, testCredentialFile) assert.NoError(t, err) - cred2, err := ReadCredentialsFromFile(testCredentialFile) + cred2, err := ReadCredentials(testCredentialFile) assert.NoError(t, err) assert.NotEmpty(t, cred2) assert.Equal(t, cred1, cred2) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index aed5e22..ea9d9df 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -34,6 +34,9 @@ var ( // ErrNoToken indicates that no bearer token was provided ErrNoToken = errors.New("no token provided") + // ErrTokenExpired indicates that the token is no longer valid + ErrTokenExpired = errors.New("token expired") + // ErrNoScope indicates that no scope was provided ErrNoScope = errors.New("no scope provided") @@ -51,7 +54,7 @@ func init() { // CheckAuthorization relies on the presence of a bearer token and validates the // matching authorization against a list of requested scopes. If everything checks out, // the function returns the authorization or an error otherwise. -func CheckAuthorization(ctx context.Context, c echo.Context, scope string) (*settings.Settings, error) { +func CheckAuthorization(ctx context.Context, c echo.Context, scope string) (*settings.DialSettings, error) { token, err := GetBearerToken(c.Request()) if err != nil { return nil, err diff --git a/internal/auth/store.go b/internal/auth/store.go index 95ea247..e25d05e 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -5,17 +5,18 @@ import ( "path/filepath" "sync" - "github.com/txsvc/apikit/internal" - "github.com/txsvc/apikit/internal/settings" "github.com/txsvc/stdlib/v2" + + "github.com/txsvc/apikit/helpers" + "github.com/txsvc/apikit/internal/settings" ) type ( authCache struct { root string // location on disc // different types of lookup tables - tokenToAuth map[string]*settings.Settings - idToAuth map[string]*settings.Settings + tokenToAuth map[string]*settings.DialSettings + idToAuth map[string]*settings.DialSettings } ) @@ -32,14 +33,14 @@ func FlushAuthorizations(root string) { cache = &authCache{ root: root, - tokenToAuth: make(map[string]*settings.Settings), - idToAuth: make(map[string]*settings.Settings), + tokenToAuth: make(map[string]*settings.DialSettings), + idToAuth: make(map[string]*settings.DialSettings), } if root != "" { filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if !info.IsDir() { - cfg, err := internal.ReadSettingsFromFile(path) + cfg, err := helpers.ReadDialSettings(path) if err != nil { return err // FIXME: this is never checked on exit ! } @@ -50,37 +51,41 @@ func FlushAuthorizations(root string) { } } -func RegisterAuthorization(cfg *settings.Settings) error { +func RegisterAuthorization(cfg *settings.DialSettings) error { return cache.Register(cfg) } -func LookupByToken(token string) (*settings.Settings, error) { +func LookupByToken(token string) (*settings.DialSettings, error) { return cache.LookupByToken(token) } -func UpdateStore(cfg *settings.Settings) error { +func UpdateStore(cfg *settings.DialSettings) error { if _, err := cache.LookupByToken(cfg.Credentials.Token); err != nil { return err // only allow to write already registered settings } return cache.writeToStore(cfg) } -func (c *authCache) Register(cfg *settings.Settings) error { +func (c *authCache) Register(cfg *settings.DialSettings) error { - _log.Debugf("register auth. key=%s/%s", fileName(cfg.Credentials), cfg.Credentials.Token) + _log.Debugf("register. t=%s/%s", cfg.Credentials.Token, fileName(cfg.Credentials)) // check if the settings already exists if a, ok := c.idToAuth[cfg.Credentials.Key()]; ok { - if a.Status != -2 { - return ErrAlreadyInitialized // already exists + if a.Status == settings.StateAuthorized { + _log.Errorf("already authorized. t=%s, state=%d", a.Credentials.Token, a.Status) + return ErrAlreadyAuthorized + } + + // remove from token lookup if the token changed + if a.Credentials.Token != cfg.Credentials.Token { + delete(c.tokenToAuth, a.Credentials.Token) } - // it is OK to overwrite while still in the the init phase - delete(c.tokenToAuth, a.Credentials.Token) // FIXME: remove from tokenToAut } // write to the file store path := filepath.Join(c.root, fileName(cfg.Credentials)) - if err := cfg.WriteToFile(path); err != nil { + if err := helpers.WriteDialSettings(cfg, path); err != nil { return err } @@ -91,8 +96,8 @@ func (c *authCache) Register(cfg *settings.Settings) error { return nil } -func (c *authCache) LookupByToken(token string) (*settings.Settings, error) { - _log.Debugf("lookupByToken=%s", token) +func (c *authCache) LookupByToken(token string) (*settings.DialSettings, error) { + _log.Debugf("lookup. t=%s", token) if token == "" { return nil, ErrNoToken @@ -103,10 +108,10 @@ func (c *authCache) LookupByToken(token string) (*settings.Settings, error) { return nil, nil // FIXME: return an error ? } -func (c *authCache) writeToStore(cfg *settings.Settings) error { +func (c *authCache) writeToStore(cfg *settings.DialSettings) error { // write to the file store path := filepath.Join(c.root, fileName(cfg.Credentials)) - if err := cfg.WriteToFile(path); err != nil { + if err := helpers.WriteDialSettings(cfg, path); err != nil { return err } return nil diff --git a/internal/settings/creds.go b/internal/settings/creds.go new file mode 100644 index 0000000..33d47d5 --- /dev/null +++ b/internal/settings/creds.go @@ -0,0 +1,49 @@ +// Most of the code is lifted from +// https://github.com/googleapis/google-api-go-client/blob/main/internal/settings.go +// +// For details and copyright etc. see above url. +package settings + +import ( + "strings" + + "github.com/txsvc/stdlib/v2" +) + +type ( + Credentials struct { + ProjectID string `json:"project_id,omitempty"` // may be empty + UserID string `json:"user_id,omitempty"` // may be empty, aka client_id + Token string `json:"token,omitempty"` // may be empty + Expires int64 `json:"expires,omitempty"` // 0 = never, > 0 = unix timestamp, < 0 = invalid + } +) + +func (c *Credentials) Clone() *Credentials { + return &Credentials{ + ProjectID: c.ProjectID, + UserID: c.UserID, + Token: c.Token, + Expires: c.Expires, + } +} + +func (c *Credentials) Key() string { + return strings.ToLower(c.ProjectID + "." + c.UserID) // FIXME: make it a md5 ? +} + +// IsValid test if Crendentials is valid +func (c *Credentials) IsValid() bool { + if c.Expires < 0 || len(c.Token) == 0 || len(c.ProjectID) == 0 || len(c.UserID) == 0 { + return false + } + return !c.Expired() +} + +// Expired only verifies just that, does not check all other attributes +func (c *Credentials) Expired() bool { + if c.Expires == 0 { + return false + } + return c.Expires < stdlib.Now() +} diff --git a/internal/settings/creds_test.go b/internal/settings/creds_test.go new file mode 100644 index 0000000..58bc7f6 --- /dev/null +++ b/internal/settings/creds_test.go @@ -0,0 +1,47 @@ +package settings + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCloneCredentials(t *testing.T) { + cred := Credentials{ + ProjectID: "p", + UserID: "u", + Token: "t", + Expires: 10, + } + dup := cred.Clone() + assert.Equal(t, &cred, dup) +} + +func TestValidation(t *testing.T) { + cred1 := Credentials{} + assert.False(t, cred1.IsValid()) + + cred2 := Credentials{ + ProjectID: "p", + UserID: "u", + Token: "t", + Expires: 10, // forces a fail ... + } + assert.False(t, cred2.IsValid()) + + cred2.Expires = 0 + assert.True(t, cred2.IsValid()) +} + +func TestExpiration(t *testing.T) { + cred := Credentials{ + ProjectID: "p", + UserID: "u", + Token: "t", + Expires: 10, + } + assert.True(t, cred.Expired()) + + cred.Expires = 0 + assert.False(t, cred.Expired()) +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index a8dee66..a49a20a 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -4,53 +4,37 @@ // For details and copyright etc. see above url. package settings -import ( - "encoding/json" - "io/fs" - "os" - "path/filepath" - "strings" - - "github.com/txsvc/stdlib/v2" -) - const ( - indentChar = " " - filePerm fs.FileMode = 0644 + StateInit State = iota - 2 // waiting to swap tokens + StateInvalid // a config in this state should not be used + StateUndefined // logged out + StateAuthorized // logged in ) type ( - // Settings holds information needed to establish a connection with a - // backend API service or to simply configure some code. - Settings struct { - Endpoint string `json:"endpoint,omitempty"` + State int - Scopes []string `json:"scopes,omitempty"` - DefaultScopes []string `json:"default_scopes,omitempty"` + // DialSettings holds information needed to establish a connection with a + // backend API service or to simply configure a service/CLI. + DialSettings struct { + Endpoint string `json:"endpoint,omitempty"` Credentials *Credentials `json:"credentials,omitempty"` - Status int `json:"status,omitempty"` - //InternalCredentials *Credentials `json:"internal_credentials,omitempty"` - //CredentialsFile string `json:"credentials_file,omitempty"` - //NoAuth bool `json:"no_auth,omitempty"` + Scopes []string `json:"scopes,omitempty"` + DefaultScopes []string `json:"default_scopes,omitempty"` UserAgent string `json:"user_agent,omitempty"` APIKey string `json:"api_key,omitempty"` // aka ClientID - Options map[string]string `json:"options,omitempty"` // holds all other values ... - } + Status State `json:"status,omitempty"` - Credentials struct { - ProjectID string `json:"project_id,omitempty"` // may be empty - UserID string `json:"user_id,omitempty"` // may be empty, aka client_id - Token string `json:"token,omitempty"` // may be empty - Expires int64 `json:"expires,omitempty"` // 0 = never, < 0 = invalid, > 0 = unix timestamp + Options map[string]string `json:"options,omitempty"` // holds all other values ... } ) -func (ds *Settings) Clone() Settings { - s := Settings{ +func (ds *DialSettings) Clone() DialSettings { + s := DialSettings{ Endpoint: ds.Endpoint, UserAgent: ds.UserAgent, APIKey: ds.APIKey, @@ -79,7 +63,7 @@ func (ds *Settings) Clone() Settings { } // GetScopes returns the user-provided scopes, if set, or else falls back to the default scopes. -func (ds *Settings) GetScopes() []string { +func (ds *DialSettings) GetScopes() []string { if len(ds.Scopes) > 0 { return ds.Scopes } @@ -87,13 +71,13 @@ func (ds *Settings) GetScopes() []string { } // HasOption returns true if ds has a custom option opt. -func (ds *Settings) HasOption(opt string) bool { +func (ds *DialSettings) HasOption(opt string) bool { _, ok := ds.Options[opt] return ok } // GetOption returns the custom option opt if it exists or an empty string otherwise -func (ds *Settings) GetOption(opt string) string { +func (ds *DialSettings) GetOption(opt string) string { if o, ok := ds.Options[opt]; ok { return o } @@ -101,61 +85,9 @@ func (ds *Settings) GetOption(opt string) string { } // SetOptions registers a custom option o with key opt. -func (ds *Settings) SetOption(opt, o string) { +func (ds *DialSettings) SetOption(opt, o string) { if ds.Options == nil { ds.Options = make(map[string]string) } ds.Options[opt] = o } - -func (ds *Settings) WriteToFile(path string) error { - cfg, err := json.MarshalIndent(ds, "", indentChar) - if err != nil { - return err - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - os.MkdirAll(filepath.Dir(path), os.ModePerm) - } - - return os.WriteFile(path, cfg, filePerm) -} - -func (c *Credentials) Clone() *Credentials { - return &Credentials{ - ProjectID: c.ProjectID, - UserID: c.UserID, - Token: c.Token, - Expires: c.Expires, - } -} - -func (c *Credentials) Key() string { - return strings.ToLower(c.ProjectID + "." + c.UserID) // FIXME: make it a md5 ? -} - -// IsValid test if Crendentials is valid -func (c *Credentials) IsValid() bool { - // attributes must be set - if len(c.Token) == 0 || len(c.ProjectID) == 0 || len(c.UserID) == 0 { - return false - } - - if c.Expires == 0 { - return true - } - return c.Expires > stdlib.Now() -} - -func (cred *Credentials) WriteToFile(path string) error { - cfg, err := json.MarshalIndent(cred, "", indentChar) - if err != nil { - return err - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - os.MkdirAll(filepath.Dir(path), os.ModePerm) - } - - return os.WriteFile(path, cfg, filePerm) -} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index fd0d28b..c5bc5a0 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -7,19 +7,26 @@ import ( ) func TestScopes(t *testing.T) { - cfg1 := Settings{ - DefaultScopes: []string{"a", "b"}, + cfg1 := DialSettings{ + DefaultScopes: []string{"a", "b", "c"}, } assert.NotEmpty(t, cfg1.GetScopes()) - cfg2 := Settings{ + cfg2 := DialSettings{ Scopes: []string{"A", "B"}, } assert.NotEmpty(t, cfg2.GetScopes()) + + cfg3 := DialSettings{ + Scopes: []string{"A", "B"}, + DefaultScopes: []string{"a", "b", "c"}, + } + assert.NotEmpty(t, cfg3.GetScopes()) + assert.Equal(t, []string{"A", "B"}, cfg3.GetScopes()) } func TestOptions(t *testing.T) { - cfg1 := Settings{} + cfg1 := DialSettings{} assert.Nil(t, cfg1.Options) assert.False(t, cfg1.HasOption("FOO")) @@ -32,19 +39,8 @@ func TestOptions(t *testing.T) { assert.Equal(t, "x", opt) } -func TestCloneCredentials(t *testing.T) { - cred := Credentials{ - ProjectID: "p", - UserID: "u", - Token: "t", - Expires: 10, - } - dup := cred.Clone() - assert.Equal(t, &cred, dup) -} - -func TestCloneSettings(t *testing.T) { - s1 := Settings{ +func TestCloneDialSettings(t *testing.T) { + s1 := DialSettings{ Endpoint: "ep", UserAgent: "UserAgent", APIKey: "APIKey", diff --git a/internal/util.go b/internal/util.go deleted file mode 100644 index d243195..0000000 --- a/internal/util.go +++ /dev/null @@ -1,67 +0,0 @@ -package internal - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/txsvc/stdlib/v2" - - "github.com/txsvc/apikit/helpers" - "github.com/txsvc/apikit/internal/settings" -) - -func CreateSimpleToken() string { - token, _ := stdlib.UUID() - return token -} - -func ReadSettingsFromFile(path string) (*settings.Settings, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - ds := settings.Settings{} - if err := json.Unmarshal([]byte(data), &ds); err != nil { - return nil, err - } - return &ds, nil -} - -func ReadCredentialsFromFile(path string) (*settings.Credentials, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - cred := settings.Credentials{} - if err := json.Unmarshal([]byte(data), &cred); err != nil { - return nil, err - } - return &cred, nil -} - -func InitSettings(realm, userid string) (*settings.Settings, error) { - cred := InitCredentials(realm, userid) - cfg := settings.Settings{ - Credentials: &cred, - } - - // create a mnemic to derieve the 'password' from - mnemonic, err := helpers.CreateMnemonic("") - if err != nil { - return nil, err // abort here - } - cfg.APIKey = stdlib.Fingerprint(fmt.Sprintf("%s%s%s", realm, userid, mnemonic)) - cfg.SetOption("PASSPHRASE", mnemonic) // FIXME: make sure this gets NEVER saved to disc! - - return &cfg, nil -} - -func InitCredentials(realm, userid string) settings.Credentials { - return settings.Credentials{ - ProjectID: realm, - UserID: userid, - Token: CreateSimpleToken(), - Expires: 0, - } -} diff --git a/listener.go b/listener.go index 15e0736..2708ae3 100644 --- a/listener.go +++ b/listener.go @@ -12,6 +12,7 @@ import ( "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" + "github.com/txsvc/apikit/config" "github.com/txsvc/stdlib/v2" "github.com/txsvc/stdlib/v2/stdlibx/stringsx" ) @@ -38,7 +39,7 @@ func (a *App) listen(addr, certFile, keyFile string, useTLS bool) { }() if useTLS { - port := fmt.Sprintf(":%s", stringsx.TakeOne(stdlib.GetString("PORT", addr), PORT_DEFAULT_TLS)) + port := fmt.Sprintf(":%s", stringsx.TakeOne(stdlib.GetString(config.PortEnv, addr), PORT_DEFAULT_TLS)) certDir := fmt.Sprintf("%s/.cert", a.root) autoTLSManager := autocert.Manager{ @@ -61,7 +62,7 @@ func (a *App) listen(addr, certFile, keyFile string, useTLS bool) { } } else { // simply startup without TLS - port := fmt.Sprintf(":%s", stringsx.TakeOne(stdlib.GetString("PORT", addr), PORT_DEFAULT)) + port := fmt.Sprintf(":%s", stringsx.TakeOne(stdlib.GetString(config.PortEnv, addr), PORT_DEFAULT)) log.Fatal(a.mux.Start(port)) } } diff --git a/logger/logger.go b/logger/logger.go index 4279890..a3770be 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -23,6 +23,8 @@ const ( Info Warn Error + + LogLevelEnv = "LOG_LEVEL" ) type Logger struct { @@ -44,7 +46,7 @@ func NewLogger(out io.Writer, lvl string) Logger { // levelFromEnv looks for ENV['LOG_LEVEL'], returns level Info by default func levelFromEnv(lvl string) Level { - lit := strings.ToLower(stdlib.GetString("LOG_LEVEL", lvl)) + lit := strings.ToLower(stdlib.GetString(LogLevelEnv, lvl)) switch lit { default: