From 47ea7c909407af0015911b96f3feb808ff828dc7 Mon Sep 17 00:00:00 2001 From: KEINOS Date: Thu, 25 Apr 2024 01:20:46 +0900 Subject: [PATCH] fix: #33 feature functional option pattern --- .golangci.yml | 8 +-- README.md | 46 +++++++++----- totp/algorithm.go | 2 +- totp/example_test.go | 148 +++++++++++++++++++++++++++++++------------ totp/key.go | 41 ++++++++---- totp/key_test.go | 29 +++++++-- totp/options.go | 86 ++++++++++++++++++++++++- totp/options_test.go | 21 ++++++ 8 files changed, 301 insertions(+), 80 deletions(-) create mode 100644 totp/options_test.go diff --git a/.golangci.yml b/.golangci.yml index 0beb27b..917344b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,15 +1,15 @@ run: tests: true - fast: true build-tags: - golangci - skip-dirs: + allow-parallel-runners: true + +issues: + exclude-dirs: - .github - .vscode - allow-parallel-runners: true output: - format: colored-line-number sort-results: true linters: diff --git a/README.md b/README.md index d736bb1..8c5df6b 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,20 @@ ## Usage ```go +// Install package go get "github.com/KEINOS/go-totp" ``` ```go -import ( - "fmt" - "log" - - "github.com/KEINOS/go-totp/totp" -) +// import "github.com/KEINOS/go-totp/totp" func Example() { - // Generate a new secret key + // Generate a new secret key with default options: + // Algorithm: SHA1 + // Period: 30 + // Secret Size: 128 + // Skew: 0 + // Digits: 6 Issuer := "Example.com" AccountName := "alice@example.com" @@ -43,7 +44,7 @@ func Example() { if key.Validate(passcode) { fmt.Println("Passcode is valid") } - + // // Output: Passcode is valid } ``` @@ -51,21 +52,33 @@ func Example() { - [View it online](https://go.dev/play/p/s7bAGoLY25R) @ Go Playground ```go -// Generate a secret for TOTP (totp.Key object). -key, err := totp.GenerateKey(Issuer, AccountName) +// -------------------------------------------------- +// Generate a new secret key with custom options +// -------------------------------------------------- +key, err := totp.GenerateKey(Issuer, AccountName, + totp.WithAlgorithm(totp.Algorithm("SHA256")), + totp.WithPeriod(15), + totp.WithSecretSize(256), + totp.WithSkew(5), + totp.WithDigits(totp.DigitsEight), +) // -------------------------------------------------- -// Basic methods of totp.Key object +// Methods of totp.Key object // -------------------------------------------------- -// Generate the current passcode. Which is a string of -// 6 digit numbers and valid for 30 seconds by default. +// Generate the current passcode. +// +// Which is a string of 8 digit numbers and valid for +// 15 seconds with ±5 seconds skew/tolerance (as set +// in the above example). passcode, err := key.PassCode() // Validate the received passcode. ok := key.Validate(passcode) // Get 100x100 px image of QR code as PNG byte data. +// // FixLevelDefault is the 15% of error correction. qrCodeObj, err := key.QRCode(totp.FixLevelDefault) pngBytes, err := qrCodeObj.PNG(100, 100) @@ -81,6 +94,9 @@ base32Key := key.Secret.Base32() // Get the secret value in Base62 format string. base62Key := key.Secret.Base62() + +// Get the secret value in bytes. +rawKey := key.Secret.Bytes() ``` - [View __more examples__ and advanced usages](https://pkg.go.dev/github.com/KEINOS/go-totp/totp#pkg-examples) @ pkg.go.dev @@ -111,10 +127,10 @@ Any Pull-Request for improvement is welcome! - [CIs](https://github.com/KEINOS/go-totp/actions) on PR/Push: `unit-tests` `golangci-lint` `codeQL-analysis` `platform-tests` - [Security policy](https://github.com/KEINOS/go-totp/security/policy) - Help wanted - - https://github.com/KEINOS/go-totp/issues + - [https://github.com/KEINOS/go-totp/issues](https://github.com/KEINOS/go-totp/issues) ## License, copyright and credits -- MIT, Copyright (c) 2022 [The go-totp contributors](https://github.com/KEINOS/go-totp/graphs/contributors). +- MIT, Copyright (c) 2022- [The go-totp contributors](https://github.com/KEINOS/go-totp/graphs/contributors). - This Go package relies heavily on support from the `github.com/pquerna/otp` package. - [https://github.com/pquerna/otp](https://github.com/pquerna/otp) with [Apache-2.0 license](https://github.com/pquerna/otp/blob/master/LICENSE) diff --git a/totp/algorithm.go b/totp/algorithm.go index 4201000..53129d8 100644 --- a/totp/algorithm.go +++ b/totp/algorithm.go @@ -19,7 +19,7 @@ type Algorithm string // ---------------------------------------------------------------------------- // NewAlgorithmStr creates a new Algorithm object from a string. -// Choices are: MD5, SHA1, SHA256 and SHA512. +// Choices of algo are: MD5, SHA1, SHA256 and SHA512. func NewAlgorithmStr(algo string) (Algorithm, error) { const ( cMD5 = "MD5" diff --git a/totp/example_test.go b/totp/example_test.go index c898a0e..acdcaf7 100644 --- a/totp/example_test.go +++ b/totp/example_test.go @@ -1,3 +1,4 @@ +//nolint:goconst package totp_test import ( @@ -9,8 +10,13 @@ import ( "github.com/KEINOS/go-totp/totp" ) -func Example() { - // Generate a new secret key +func Example_basic() { + // Generate a new secret key with default options: + // Algorithm: SHA1 + // Period: 30 + // Secret Size: 128 + // Skew: 0 + // Digits: 6 Issuer := "Example.com" AccountName := "alice@example.com" @@ -29,7 +35,37 @@ func Example() { if key.Validate(passcode) { fmt.Println("Passcode is valid") } + // + // Output: Passcode is valid +} +func Example_custom() { + // Generate a new secret key with custom options + Issuer := "Example.com" + AccountName := "alice@example.com" + + key, err := totp.GenerateKey(Issuer, AccountName, + totp.WithAlgorithm(totp.Algorithm("SHA256")), + totp.WithPeriod(15), + totp.WithSecretSize(256), + totp.WithSkew(5), + totp.WithDigits(totp.DigitsEight), + ) + if err != nil { + log.Fatal(err) + } + + // Generate 8 digits passcode (valid for 15 ± 5 seconds) + passcode, err := key.PassCode() + if err != nil { + log.Fatal(err) + } + + // Validate the passcode + if key.Validate(passcode) { + fmt.Println("Passcode is valid") + } + // // Output: Passcode is valid } @@ -61,7 +97,7 @@ func Example_advanced() { if key.Validate(passcode) { fmt.Println("Passcode is valid") } - + // // Output: Passcode is valid } @@ -80,7 +116,7 @@ func ExampleAlgorithm() { fmt.Println("Algorithm:", algo.String()) fmt.Println("Algorithm ID:", algo.ID()) fmt.Printf("Type: %T\n", algo.OTPAlgorithm()) - + // // Output: // Algorithm: SHA512 // Algorithm ID: 2 @@ -88,7 +124,7 @@ func ExampleAlgorithm() { } func ExampleAlgorithm_IsSupported() { - // Cast a string to Algorithm type + // Set unsupported algorithm algo := totp.Algorithm("BLAKE3") // Check if the algorithm is supported @@ -97,7 +133,7 @@ func ExampleAlgorithm_IsSupported() { } else { fmt.Println("Algorithm is not supported") } - + // // Output: Algorithm is not supported } @@ -122,7 +158,7 @@ func ExampleDigits() { if totp.DigitsSix == totp.NewDigitsInt(6) { fmt.Println("Digit 6", "OK") } - + // // Output: // Digits: 8 // Digits ID: 8 @@ -161,7 +197,7 @@ gX7ff3VlT4sCakCjQH69ZQxTbzs= fmt.Println("Secret Size:", key.Options.SecretSize) fmt.Println("Skew:", key.Options.Skew) fmt.Println("Secret:", key.Secret.Base32()) - + // // Output: // AccountName: alice@example.com // Algorithm: SHA1 @@ -177,7 +213,6 @@ gX7ff3VlT4sCakCjQH69ZQxTbzs= // Function: GenerateKeyURI // ---------------------------------------------------------------------------- -//nolint:goconst // origin appears many times but leave it as is as example. func ExampleGenerateKeyURI() { origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + "digits=12&issuer=Example.com&period=60&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" @@ -194,7 +229,7 @@ func ExampleGenerateKeyURI() { fmt.Println("Period:", key.Options.Period) fmt.Println("Secret Size:", key.Options.SecretSize) fmt.Println("Secret:", key.Secret.String()) - + // // Output: // Issuer: Example.com // AccountName: alice@example.com @@ -221,12 +256,42 @@ func ExampleKey() { fmt.Println("Issuer:", key.Options.AccountName) fmt.Println("AccountName:", key.Options.AccountName) - + // // Output: // Issuer: alice@example.com // AccountName: alice@example.com } +func ExampleKey_PEM() { + origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + + "digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" + + key, err := totp.GenerateKeyURI(origin) + if err != nil { + log.Fatal(err) + } + + keyPEM, err := key.PEM() + if err != nil { + log.Fatal(err) + } + + fmt.Println(keyPEM) + // + // Output: + // -----BEGIN TOTP SECRET KEY----- + // Account Name: alice@example.com + // Algorithm: SHA1 + // Digits: 6 + // Issuer: Example.com + // Period: 30 + // Secret Size: 20 + // Skew: 0 + // + // gX7ff3VlT4sCakCjQH69ZQxTbzs= + // -----END TOTP SECRET KEY----- +} + //nolint:funlen // length is 62 lines long but leave it as is due to embedded example. func ExampleKey_QRCode() { origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + @@ -289,38 +354,39 @@ func ExampleKey_QRCode() { if expect == actual { fmt.Println("OK") } - + // // Output: OK } -func ExampleKey_PEM() { - origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + - "digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" +func ExampleKey_String() { + origin := ` +-----BEGIN TOTP SECRET KEY----- +Account Name: alice@example.com +Algorithm: SHA1 +Digits: 12 +Issuer: Example.com +Period: 60 +Secret Size: 20 +Skew: 0 - key, err := totp.GenerateKeyURI(origin) - if err != nil { - log.Fatal(err) - } +gX7ff3VlT4sCakCjQH69ZQxTbzs= +-----END TOTP SECRET KEY----- +` - keyPEM, err := key.PEM() + key, err := totp.GenKeyFromPEM(origin) if err != nil { log.Fatal(err) } - fmt.Println(keyPEM) + expect := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + + "digits=12&issuer=Example.com&period=60&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" + actual := key.String() - // Output: - // -----BEGIN TOTP SECRET KEY----- - // Account Name: alice@example.com - // Algorithm: SHA1 - // Digits: 6 - // Issuer: Example.com - // Period: 30 - // Secret Size: 20 - // Skew: 0 + if expect == actual { + fmt.Println("URI returned as expected") + } // - // gX7ff3VlT4sCakCjQH69ZQxTbzs= - // -----END TOTP SECRET KEY----- + // Output: URI returned as expected } func ExampleKey_URI() { @@ -338,7 +404,7 @@ func ExampleKey_URI() { if expect == actual { fmt.Println("URI returned as expected") } - + // // Output: URI returned as expected } @@ -368,7 +434,7 @@ func ExampleNewOptions() { if opt2 != nil { log.Fatal("NewOptions() should return nil on error") } - + // // Output: // Type: *totp.Options // Issuer: Example.com @@ -392,7 +458,7 @@ func ExampleNewSecretBytes() { fmt.Println("Secret string:", secret.String()) fmt.Println("Secret Base32:", secret.Base32()) fmt.Println("Secret Base62:", secret.Base62()) - + // // Output: // Type: totp.Secret // Value: totp.Secret{0x73, 0x6f, 0x6d, 0x65, 0x20, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74} @@ -432,7 +498,7 @@ func ExampleOptions() { // Skew is an acceptable range of time before and after. Value of 1 allows // up to Period of either side of the specified time. fmt.Println("Skew:", options.Skew) - + // // Output: // Issuer: Example.com // AccountName: alice@example.com @@ -479,7 +545,7 @@ func ExampleSecret() { if secret32.String() == secret62.String() { fmt.Println("Two secrets are the same.") } - + // // Output: // Get as base62 encoded string: FegjEGvm7g03GQye // Get as base32 encoded string: MZXW6IDCMFZCAYTVPJ5A @@ -502,7 +568,7 @@ func ExampleStrToUint() { uint2 := totp.StrToUint(str2) fmt.Printf("uint2: %v, type: %T\n", uint2, uint2) - + // // Output: // uint1: 1234567890, type: uint // uint2: 0, type: uint @@ -535,7 +601,7 @@ func ExampleURI() { fmt.Println("Secret:", uri.Secret().String()) fmt.Println("Period:", uri.Period()) fmt.Println("Digits:", uri.Digits()) - + // // Output: // Raw URI and String is equal: OK // Scheme: otpauth @@ -554,7 +620,7 @@ func ExampleURI_IssuerFromPath() { uri := totp.URI(origin) fmt.Println(uri.IssuerFromPath()) - + // // Output: Example.com } @@ -591,7 +657,7 @@ func ExampleValidate() { if totp.Validate(passcode, secret, options) { fmt.Println("Passcode is valid. Checked via Validate() function.") } - + // // Output: // Passcode is valid. Checked via Key.Validate() method. // Passcode is valid. Checked via Validate() function. diff --git a/totp/key.go b/totp/key.go index a978333..bd2c50a 100644 --- a/totp/key.go +++ b/totp/key.go @@ -32,24 +32,32 @@ type Key struct { // SHA-512 hash for HMAC, 30 seconds of period, 64 byte size of secret and // 6 digits of passcode. // -// To specify custom options, use GenerateKeyCustom(). -func GenerateKey(issuer string, accountName string) (*Key, error) { - //nolint:exhaustruct // allow fields to be missing so to set defaults later - opt := Options{ - Issuer: issuer, - AccountName: accountName, +// To customize the options, use the With* functions from the options.go file. +// For advanced customization, use GenerateKeyCustom() instead. +func GenerateKey(issuer string, accountName string, opts ...Option) (*Key, error) { + // Create options with default values. + optsCustom, err := NewOptions(issuer, accountName) + if err != nil { + return nil, errors.Wrap(err, "failed to create options during key generation") } - opt.SetDefault() + // Apply custom options. + for _, fn := range opts { + if err := fn(optsCustom); err != nil { + return nil, errors.Wrap(err, "failed to apply custom options") + } + } - return GenerateKeyCustom(opt) + return GenerateKeyCustom(*optsCustom) } //nolint:gochecknoglobals // allow private global variable to mock during tests var totpGenerate = totp.Generate -// GenerateKeyCustom creates a new Key object with custom options. With this -// function you can specify the algorithm, period, secret size and digits. +// GenerateKeyCustom creates a new Key object with custom options. +// +// Usually, `GenerateKey` with options is enough for most cases. But if you need +// more control over the options, use this function. func GenerateKeyCustom(options Options) (*Key, error) { tmpOpt := totp.GenerateOpts{ Issuer: options.Issuer, @@ -153,6 +161,9 @@ func GenerateKeyURI(uri string) (*Key, error) { // Methods // ---------------------------------------------------------------------------- +//nolint:gochecknoglobals // allow private global variable to mock during tests +var pemEncodeToMemory = pem.EncodeToMemory + // PassCode generates a 6 or 8 digits passcode for the current time. // The output string will be eg. "123456" or "12345678". func (k *Key) PassCode() (string, error) { @@ -169,9 +180,6 @@ func (k *Key) PassCode() (string, error) { ) } -//nolint:gochecknoglobals // allow private global variable to mock during tests -var pemEncodeToMemory = pem.EncodeToMemory - // PEM returns the key in PEM formatted string. func (k *Key) PEM() (string, error) { out := pemEncodeToMemory(&pem.Block{ @@ -210,6 +218,13 @@ func (k *Key) QRCode(fixLevel FixLevel) (*QRCode, error) { return qrCode, nil } +// String returns a string representation of the key in URI format. +// +// It is an implementation of the fmt.Stringer interface. +func (k *Key) String() string { + return k.URI() +} + // URI returns the key in OTP URI format. // // It re-generates the URI from the values stored in the Key object and will not diff --git a/totp/key_test.go b/totp/key_test.go index 141c527..503aee5 100644 --- a/totp/key_test.go +++ b/totp/key_test.go @@ -20,9 +20,29 @@ func TestGenerateKey_missing_issuer(t *testing.T) { key, err := GenerateKey("", "alice@example.com") - require.Error(t, err, "missing issuer should return error") - require.Nil(t, key) - require.Contains(t, err.Error(), "failed to generate key: Issuer must be set") + require.Error(t, err, + "missing issuer should return error") + require.Nil(t, key, + "returned key should be nil on error") + require.Contains(t, err.Error(), + "failed to create options during key generation: issuer and accountName are required") +} + +func TestGenerateKey_bad_option(t *testing.T) { + t.Parallel() + + key, err := GenerateKey( + "Example.com", + "alice@example.com", + WithAlgorithm(Algorithm("BADALGO")), + ) + + require.Error(t, err, + "missing issuer should return error") + require.Nil(t, key, + "returned key should be nil on error") + require.Contains(t, err.Error(), + "failed to apply custom options: unsupported algorithm: BADALGO") } // ---------------------------------------------------------------------------- @@ -42,7 +62,7 @@ func TestGenerateKeyCustom_wrong_digits(t *testing.T) { // URI with bad secret format //nolint:lll // ignore long line length due to URI url := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&digits=6&issuer=Example.com&period=30&secret=BADSECRET$$" - //nolint:wrapcheck // ignore error wrap check + return origOtp.NewKeyFromURL(url) } @@ -187,7 +207,6 @@ func TestGenerateKeyURI_error_msg(t *testing.T) { return nil, errors.New("forced error") } - //nolint:goconst // many occurrences but leave it key2, err := GenerateKeyURI("otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + "digits=12&issuer=Example.com&period=60&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3") diff --git a/totp/options.go b/totp/options.go index df9b362..c353ab4 100644 --- a/totp/options.go +++ b/totp/options.go @@ -11,10 +11,94 @@ const ( OptionDigitsDefault = Digits(6) // Google Authenticator does not work other than 6 digits. ) +// ============================================================================ +// Type: Option +// ============================================================================ + +type Option func(*Options) error + // ---------------------------------------------------------------------------- -// Type: Options +// Option Patterns // ---------------------------------------------------------------------------- +const errNilOptions = "options is nil" + +// WithAlgorithm sets the Algorithm to use for HMAC (Default: Algorithm("SHA512")). +func WithAlgorithm(algo Algorithm) Option { + return func(opts *Options) error { + if opts == nil { + return errors.New(errNilOptions) + } + + if !algo.IsSupported() { + return errors.New("unsupported algorithm: " + algo.String()) + } + + opts.Algorithm = algo + + return nil + } +} + +// WithPeriod sets the number of seconds a TOTP hash is valid for (Default: 30 seconds). +func WithPeriod(period uint) Option { + return func(opts *Options) error { + if opts == nil { + return errors.New(errNilOptions) + } + + opts.Period = period + + return nil + } +} + +// WithSecretSize sets the size of the generated Secret (Default: 128 bytes). +func WithSecretSize(size uint) Option { + return func(opts *Options) error { + if opts == nil { + return errors.New(errNilOptions) + } + + opts.SecretSize = size + + return nil + } +} + +// WithSkew sets the periods before or after the current time to allow. +// Value of 1 allows up to Period of either side of the specified time. +// Defaults to 0 allowed skews. Values greater than 1 are likely sketchy. +func WithSkew(skew uint) Option { + return func(opts *Options) error { + if opts == nil { + return errors.New(errNilOptions) + } + + opts.Skew = skew + + return nil + } +} + +// WithDigits sets the Digits to request TOTP code. +// DigitsSix or DigitsEight (Default: DigitsSix). +func WithDigits(digits Digits) Option { + return func(opts *Options) error { + if opts == nil { + return errors.New(errNilOptions) + } + + opts.Digits = digits + + return nil + } +} + +// ============================================================================ +// Type: Options +// ============================================================================ + // Options is a struct that holds the options for a TOTP key. type Options struct { // Issuer is the name of the issuer of the secret key. (eg, organization, company, domain) diff --git a/totp/options_test.go b/totp/options_test.go new file mode 100644 index 0000000..ee1e017 --- /dev/null +++ b/totp/options_test.go @@ -0,0 +1,21 @@ +package totp + +import "testing" + +func TestOption_nil_input(t *testing.T) { + t.Parallel() + + for index, opt := range []Option{ + WithAlgorithm(Algorithm("SHA1")), + WithPeriod(30), + WithSecretSize(128), + WithSkew(0), + WithDigits(DigitsSix), + } { + err := opt(nil) + + if err == nil { + t.Errorf("Test %d: expected error, got nil", index) + } + } +}