diff --git a/README.md b/README.md index de2d887..d43aa25 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [x] [ip](ip) parse, match, convert, info. - [x] [json](json) support multi json packages. - [x] [number](number) round, bytes convert. +- [x] [otp](otp) `TOTP`, `HOTP`. - [x] [random](random) rand, random. - [x] [uuid](uuid) requestID, go.uuid, ksuid, xid. - [x] [xlo](xlo) some utils ref *lo*, more pls use [lo](https://github.com/samber/lo) directly. diff --git a/go.mod b/go.mod index 9e28462..7044241 100644 --- a/go.mod +++ b/go.mod @@ -7,21 +7,22 @@ require ( github.com/bytedance/sonic v1.5.0 github.com/goccy/go-json v0.9.11 github.com/json-iterator/go v1.1.12 + github.com/pquerna/otp v1.3.0 github.com/rs/xid v1.4.0 + github.com/samber/lo v1.33.0 github.com/satori/go.uuid v1.2.0 github.com/segmentio/ksuid v1.0.4 github.com/smartystreets/goconvey v1.7.2 ) require ( - github.com/bits-and-blooms/bitset v1.3.3 // indirect + github.com/boombuler/barcode v1.0.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/samber/lo v1.33.0 // indirect github.com/smartystreets/assertions v1.13.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect diff --git a/go.sum b/go.sum index d2b7bda..2d15ea0 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,8 @@ github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= -github.com/bits-and-blooms/bitset v1.3.3 h1:R1XWiopGiXf66xygsiLpzLo67xEYvMkHw3w+rCOSAwg= -github.com/bits-and-blooms/bitset v1.3.3/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.5.0 h1:XWdTi8bwPgxIML+eNV1IwNuTROK6EUrQ65ey8yd6fRQ= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06 h1:1sDoSuDPWzhkdzNVxCxtIaKiAe96ESVPv8coGwc1gZ4= @@ -21,12 +22,16 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= +github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/samber/lo v1.33.0 h1:2aKucr+rQV6gHpY3bpeZu69uYoQOzVhGT3J22Op6Cjk= @@ -42,8 +47,9 @@ github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hg github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= @@ -55,8 +61,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/otp/README.md b/otp/README.md new file mode 100644 index 0000000..d5f369f --- /dev/null +++ b/otp/README.md @@ -0,0 +1,60 @@ +# OTP: One-Time Password + +>[Time-based one-time password](https://en.wikipedia.org/wiki/Time-based_one-time_password#Client_implementations) + +Referenced the following packages: +- [pyotp](https://github.com/pyauth/pyotp) +- [otp](https://github.com/pquerna/otp) +- [Key-Uri-Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) + +## Formula + +`OTP(K, C) = Truncate(HMAC-SHA-1(K, C))` + +>`HMAC` supports the following algorithms: +> - SHA1 (Default) +> - SHA256 +> - SHA512 +> - MD5 + +## Why + +`kit4go/otp` is a Go library for generating and verifying one-time passwords. It can be used to implement two-factor (2FA) +or multi-factor (MFA) authentication methods in web applications and in other systems that require users to log in. + +It enables you to easily add TOTPs to your own application, increasing your user's security against mass-password breaches and malware. + +## Features + +- Generating QR Code images for easy user enrollment. +- Time-based One-time Password Algorithm (TOTP) (RFC 6238): Time based OTP, _the most commonly used method_. +- HMAC-based One-time Password Algorithm (HOTP) (RFC 4226): Counter based OTP, which TOTP is based upon. +- Generation and Validation of codes for either algorithm. + +## Shall Know + +- OTPs involve a shared secret, stored both on the device(client) and the server +- OTPs can be generated on a device without internet connectivity +- OTPs should always be used as a second factor of authentication (if your device is lost, you account is still secured with a password) +- Microsoft Authenticator, Google Authenticator and other OTP client apps allow you to store multiple OTP secrets and provision those using a QR Code + +## Usage + +- secret key + - `RandomSecret(length int) string` generates a random secret, b32NoPadding. + - `VerifySecret(secret string) bool` verifies the secret is base32. +- otp url + - `GenerateURLHOTP(opts KeyOpts) string` generates the hotp url + - `GenerateURLTOTP(opts KeyOpts) string` generates the totp url +- **code totp** _most commonly used_ + - `Code(secret string) string` generates the totp code + - `CodeCustom(secret string, t time.Time) string` generates the totp code with time + - `TOTPCode(secret string) (code string)` generates the totp code + - `TOTPCodeCustom(secret string, t time.Time, opts *Opts) string` generates the totp code with time and opts + - `VerifyTOTP(passcode string, secret string) bool` verifies the code of totp + - `VerifyTOTPCustom(passcode string, secret string, t time.Time, opts *Opts) bool` verifies the code of totp with opts +- code hotp + - `HOTPCode(secret string, counter uint64) string` generates the hotp code + - `HOTPCodeCustom(secret string, counter uint64, opts *Opts) string` generates the hotp code with the opts + - `VerifyHOTP(passcode string, counter uint64, secret string) bool` verifies the code of hotp + - `VerifyHOTPCustom(passcode string, counter uint64, secret string, opts *Opts) bool` verifies the code of hotp with opts diff --git a/otp/key.go b/otp/key.go new file mode 100644 index 0000000..b22c7b3 --- /dev/null +++ b/otp/key.go @@ -0,0 +1,183 @@ +package otp + +import ( + "crypto/rand" + "encoding/base32" + "errors" + "io" + "net/url" + "strconv" + "strings" + + xtp "github.com/pquerna/otp" + "github.com/pquerna/otp/hotp" + "github.com/pquerna/otp/totp" +) + +const ( + // AlgorithmSHA1 should be used for compatibility with Google Authenticator. + // + // See https://github.com/pquerna/otp/issues/55 for additional details. + AlgorithmSHA1 = xtp.AlgorithmSHA1 + AlgorithmSHA256 = xtp.AlgorithmSHA256 + AlgorithmSHA512 = xtp.AlgorithmSHA512 + AlgorithmMD5 = xtp.AlgorithmMD5 +) + +var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +// KeyOpts provides options for Generate(). The default values +// are compatible with Google-Authenticator. +// +// Required: Issuer, AccountName, htop also need counter. +type KeyOpts struct { + // Name of the issuing Organization/Company. + Issuer string + // Name of the User's Account (eg, email address) + AccountName string + // Number of seconds a TOTP hash is valid for. Defaults to 30 seconds. + Period uint + // Size in size of the generated Secret. Defaults to 20 bytes. + SecretSize uint + // Secret to store. Defaults to a randomly generated secret of SecretSize. You should generally leave this empty. + Secret []byte + // Digits to request. Defaults to 6. + Digits xtp.Digits + // Algorithm to use for HMAC. Defaults to SHA1. + Algorithm xtp.Algorithm + // Reader to use for generating TOTP Key. + Rand io.Reader + // Counter for HOTP. if type is hotp: The counter parameter is required when provisioning a key for use with HOTP. It will set the initial counter value. + Counter uint64 +} + +// RandomSecret generates a random secret of given length (number of bytes) without padding, +// if rand.Read failed returns empty string. +func RandomSecret(length int) (secret string) { + secretB := make([]byte, length) + gen, err := rand.Read(secretB) + if err != nil || gen != length { + return secret + } + secret = b32NoPadding.EncodeToString(secretB) + return +} + +// VerifySecret verifies the secret is valid, support padding or NoPadding format. +func VerifySecret(secret string) bool { + secret = strings.TrimSpace(secret) + if n := len(secret) % 8; n != 0 { + secret = secret + strings.Repeat("=", 8-n) + } + _, err := base32.StdEncoding.DecodeString(secret) + return err == nil +} + +// GenerateURLHOTP returns the HOTP URL as a string. +func GenerateURLHOTP(opts KeyOpts) (url string) { + if key, err := simpleURL(opts, "hotp"); err == nil { + key.Type() + url = key.URL() + } + return +} + +// GenerateURLTOTP returns the TOTP URL as a string. +func GenerateURLTOTP(opts KeyOpts) (url string) { + if key, err := simpleURL(opts, "totp"); err == nil { + url = key.URL() + } + return +} + +// KeyFromTOTPOpts creates a new TOTP Key. +func KeyFromTOTPOpts(opts KeyOpts) (*xtp.Key, error) { + return totp.Generate(totp.GenerateOpts{ + Issuer: opts.Issuer, + AccountName: opts.AccountName, + Period: opts.Period, + Secret: opts.Secret, + SecretSize: opts.SecretSize, + Digits: opts.Digits, + Algorithm: opts.Algorithm, + Rand: opts.Rand, + }) +} + +// KeyFromHOTPOpts creates a new HOTP Key. +func KeyFromHOTPOpts(opts KeyOpts) (*xtp.Key, error) { + return hotp.Generate(hotp.GenerateOpts{ + Issuer: opts.Issuer, + AccountName: opts.AccountName, + Secret: opts.Secret, + SecretSize: opts.SecretSize, + Digits: opts.Digits, + Algorithm: opts.Algorithm, + Rand: opts.Rand, + }) +} + +// KeyFromURL creates a new Key from an TOTP or HOTP url. +// +// The URL format is documented here: +// +// https://github.com/google/google-authenticator/wiki/Key-Uri-Format +func KeyFromURL(url string) (*xtp.Key, error) { + if len(url) == 0 { + return nil, errors.New("empty URL") + } + return xtp.NewKeyFromURL(url) +} + +func simpleURL(opts KeyOpts, otpType string) (*xtp.Key, error) { + // url encode the Issuer/AccountName + if opts.Issuer == "" { + return nil, xtp.ErrGenerateMissingIssuer + } + + if opts.AccountName == "" { + return nil, xtp.ErrGenerateMissingAccountName + } + + if opts.SecretSize == 0 { + opts.SecretSize = 10 + } + + if opts.Rand == nil { + opts.Rand = rand.Reader + } + + // otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example + + v := url.Values{} + if len(opts.Secret) != 0 { + v.Set("secret", b32NoPadding.EncodeToString(opts.Secret)) + } else { + secret := make([]byte, opts.SecretSize) + _, _ = opts.Rand.Read(secret) + v.Set("secret", b32NoPadding.EncodeToString(secret)) + } + + v.Set("issuer", opts.Issuer) + if opts.Digits == 0 { + opts.Digits = xtp.DigitsSix + } else { + v.Set("digits", opts.Digits.String()) + } + if opts.Algorithm != xtp.AlgorithmSHA1 { + v.Set("algorithm", opts.Algorithm.String()) + } + + if otpType == "hotp" { + v.Set("counter", strconv.FormatUint(opts.Counter, 10)) + } + + u := url.URL{ + Scheme: "otpauth", + Host: otpType, + Path: "/" + opts.Issuer + ":" + opts.AccountName, + RawQuery: v.Encode(), + } + + return xtp.NewKeyFromURL(u.String()) +} diff --git a/otp/key_test.go b/otp/key_test.go new file mode 100644 index 0000000..7696374 --- /dev/null +++ b/otp/key_test.go @@ -0,0 +1,119 @@ +package otp_test + +import ( + "crypto/rand" + "encoding/base32" + "errors" + "testing" + + "github.com/agiledragon/gomonkey" + "github.com/smartystreets/goconvey/convey" + + "github.com/v8fg/kit4go/otp" +) + +var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +func TestRandomSecret(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestRandomSecret", t, func() { + convey.Convey("TestRandomSecret-Failed", func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{0, nil}, Times: 1}, + {Values: gomonkey.Params{0, errors.New("error")}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(rand.Read, outputs) + defer af.Reset() + convey.So(otp.RandomSecret(6), convey.ShouldEqual, "") + convey.So(otp.RandomSecret(6), convey.ShouldEqual, "") + }) + + convey.Convey("TestRandomSecret-Success", func() { + code := otp.RandomSecret(4) + decodeString, _ := b32NoPadding.DecodeString(code) + convey.So(decodeString, convey.ShouldHaveLength, 4) + code = otp.RandomSecret(6) + decodeString, _ = b32NoPadding.DecodeString(code) + convey.So(decodeString, convey.ShouldHaveLength, 6) + }) + }) +} + +func TestVerifySecret(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestVerifySecret", t, func() { + convey.So(otp.VerifySecret("7ZDW4TVCYM"), convey.ShouldBeTrue) + convey.So(otp.VerifySecret("JBSWY3DPEHPK3PXP"), convey.ShouldBeTrue) + }) +} + +func TestGenerateURLHOTP(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestGenerateURLHOTP", t, func() { + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: ""}), convey.ShouldBeEmpty) + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: "xwi88"}), convey.ShouldBeEmpty) + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com"}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", Algorithm: otp.AlgorithmSHA512}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", Secret: []byte("7ZDW4TVCYM")}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", SecretSize: uint(0)}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLHOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", Secret: []byte("7ZDW4TVCYM"), Digits: 8}), convey.ShouldNotBeEmpty) + }) +} + +func TestGenerateURLTOTP(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestGenerateURLTOTP", t, func() { + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: ""}), convey.ShouldBeEmpty) + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: "xwi88"}), convey.ShouldBeEmpty) + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com"}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", Algorithm: otp.AlgorithmSHA512}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", Secret: []byte("7ZDW4TVCYM")}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", SecretSize: uint(0)}), convey.ShouldNotBeEmpty) + convey.So(otp.GenerateURLTOTP(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com", Secret: []byte("7ZDW4TVCYM"), Digits: 8}), convey.ShouldNotBeEmpty) + }) +} + +func TestKeyFromURL(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestKeyFromURL", t, func() { + key, err := otp.KeyFromURL("") + convey.So(key, convey.ShouldBeNil) + convey.So(err, convey.ShouldBeError) + key, err = otp.KeyFromURL("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example") + convey.So(key, convey.ShouldNotBeNil) + convey.So(err, convey.ShouldBeNil) + key, err = otp.KeyFromURL("otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30") + convey.So(key, convey.ShouldNotBeNil) + convey.So(err, convey.ShouldBeNil) + }) +} + +func TestKeyFromHOTPOpts(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestKeyFromHOTPOpts", t, func() { + key, err := otp.KeyFromHOTPOpts(otp.KeyOpts{Issuer: ""}) + convey.So(key, convey.ShouldBeNil) + convey.So(err, convey.ShouldBeError) + key, err = otp.KeyFromHOTPOpts(otp.KeyOpts{Issuer: "xwi88"}) + convey.So(key, convey.ShouldBeNil) + convey.So(err, convey.ShouldBeError) + key, err = otp.KeyFromHOTPOpts(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com"}) + convey.So(key, convey.ShouldNotBeNil) + convey.So(err, convey.ShouldBeNil) + }) +} + +func TestKeyFromTOTPOpts(t *testing.T) { + convey.SetDefaultFailureMode(convey.FailureContinues) + convey.Convey("TestKeyFromTOTPOpts", t, func() { + key, err := otp.KeyFromTOTPOpts(otp.KeyOpts{Issuer: ""}) + convey.So(key, convey.ShouldBeNil) + convey.So(err, convey.ShouldBeError) + key, err = otp.KeyFromTOTPOpts(otp.KeyOpts{Issuer: "xwi88"}) + convey.So(key, convey.ShouldBeNil) + convey.So(err, convey.ShouldBeError) + key, err = otp.KeyFromTOTPOpts(otp.KeyOpts{Issuer: "xwi88", AccountName: "xwi88.com"}) + convey.So(key, convey.ShouldNotBeNil) + convey.So(err, convey.ShouldBeNil) + }) +} diff --git a/otp/otp.go b/otp/otp.go new file mode 100644 index 0000000..4106dff --- /dev/null +++ b/otp/otp.go @@ -0,0 +1,117 @@ +package otp + +import ( + "time" + + xtp "github.com/pquerna/otp" + "github.com/pquerna/otp/hotp" + "github.com/pquerna/otp/totp" +) + +// Opts provides options for ValidateCustom(). +// +// Only for TOTP: Period, Skew. +type Opts struct { + // Number of seconds a TOTP hash is valid for. Defaults to 30 seconds. + Period uint + // 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. + Skew uint + // Digits as part of the input. Defaults to 6. + Digits xtp.Digits + // Algorithm to use for HMAC. Defaults to SHA1. + Algorithm xtp.Algorithm +} + +func (opts *Opts) GetPeriod() uint { + if opts == nil || opts.Period == 0 { + return 30 + } + return opts.Period +} + +func (opts *Opts) GetSkew() uint { + if opts == nil { + return 0 + } + return opts.Skew +} + +func (opts *Opts) GetDigits() xtp.Digits { + if opts == nil || opts.Digits == 0 { + return xtp.DigitsSix + } + return opts.Digits +} + +func (opts *Opts) GetAlgorithm() xtp.Algorithm { + if opts == nil { + return xtp.AlgorithmSHA1 + } + return opts.Algorithm +} + +// Code generates the totp code, with the default settings: digits=6, algorithm=SHA1, base now timestamp. +func Code(secret string) string { + return TOTPCode(secret) +} + +// CodeCustom generates the totp code, with the default settings: digits=6, algorithm=SHA1, with your specified timestamp. +func CodeCustom(secret string, t time.Time) string { + return TOTPCodeCustom(secret, t, nil) +} + +func TOTPCode(secret string) (code string) { + code, _ = totp.GenerateCode(secret, time.Now()) + return +} + +func TOTPCodeCustom(secret string, t time.Time, opts *Opts) (code string) { + code, _ = totp.GenerateCodeCustom(secret, t, totp.ValidateOpts{ + Period: opts.GetPeriod(), + Skew: opts.GetSkew(), + Digits: opts.GetDigits(), + Algorithm: opts.GetAlgorithm(), + }) + return +} + +func HOTPCode(secret string, counter uint64) (code string) { + code, _ = hotp.GenerateCode(secret, counter) + return +} + +func HOTPCodeCustom(secret string, counter uint64, opts *Opts) (code string) { + code, _ = hotp.GenerateCodeCustom(secret, counter, hotp.ValidateOpts{ + Digits: opts.GetDigits(), + Algorithm: opts.GetAlgorithm(), + }) + return +} + +func VerifyTOTP(passcode string, secret string) bool { + return totp.Validate(passcode, secret) +} + +func VerifyTOTPCustom(passcode string, secret string, t time.Time, opts *Opts) (ret bool) { + ret, _ = totp.ValidateCustom(passcode, secret, t, totp.ValidateOpts{ + Period: opts.GetPeriod(), + Skew: opts.GetSkew(), + Digits: opts.GetDigits(), + Algorithm: opts.GetAlgorithm(), + }) + return +} + +func VerifyHOTP(passcode string, counter uint64, secret string) bool { + return hotp.Validate(passcode, counter, secret) +} + +func VerifyHOTPCustom(passcode string, counter uint64, secret string, opts *Opts) (ret bool) { + ret, _ = hotp.ValidateCustom(passcode, counter, secret, hotp.ValidateOpts{ + Digits: opts.GetDigits(), + Algorithm: opts.GetAlgorithm(), + }) + return +} diff --git a/otp/otp_test.go b/otp/otp_test.go new file mode 100644 index 0000000..385db63 --- /dev/null +++ b/otp/otp_test.go @@ -0,0 +1,151 @@ +package otp_test + +import ( + "testing" + "time" + + "github.com/agiledragon/gomonkey" + "github.com/pquerna/otp/hotp" + "github.com/pquerna/otp/totp" + "github.com/smartystreets/goconvey/convey" + + "github.com/v8fg/kit4go/otp" +) + +func TestCode(t *testing.T) { + convey.Convey("TestCode", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{"563324"}, Times: 1}, + {Values: gomonkey.Params{"487978"}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(otp.TOTPCode, outputs) + defer af.Reset() + convey.So(otp.Code("JBSWY3DPEHPK3PXP"), convey.ShouldEqual, "563324") + convey.So(otp.Code("JBSWY3DPEHPK3PXP"), convey.ShouldEqual, "487978") + }) +} + +func TestCodeCustom(t *testing.T) { + convey.Convey("TestCodeCustom", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{"385109"}, Times: 1}, + {Values: gomonkey.Params{"833446"}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(otp.TOTPCodeCustom, outputs) + defer af.Reset() + convey.So(otp.CodeCustom("JBSWY3DPEHPK3PXP", time.Now()), convey.ShouldEqual, "385109") + convey.So(otp.CodeCustom("JBSWY3DPEHPK3PXP", time.Now()), convey.ShouldEqual, "833446") + }) +} + +func TestTOTPCode(t *testing.T) { + convey.Convey("TestTOTPCode", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{"563324", nil}, Times: 1}, + {Values: gomonkey.Params{"487978", nil}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(totp.GenerateCode, outputs) + defer af.Reset() + convey.So(otp.TOTPCode("JBSWY3DPEHPK3PXP"), convey.ShouldEqual, "563324") + convey.So(otp.TOTPCode("JBSWY3DPEHPK3PXP"), convey.ShouldEqual, "487978") + }) +} + +func TestTOTPCodeCustom(t *testing.T) { + convey.Convey("TestTOTPCodeCustom", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{"563324", nil}, Times: 1}, + {Values: gomonkey.Params{"487978", nil}, Times: 1}, + {Values: gomonkey.Params{"008395", nil}, Times: 1}, + {Values: gomonkey.Params{"116644", nil}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(totp.GenerateCodeCustom, outputs) + defer af.Reset() + convey.So(otp.TOTPCodeCustom("JBSWY3DPEHPK3PXP", time.Now(), nil), convey.ShouldEqual, "563324") + convey.So(otp.TOTPCodeCustom("JBSWY3DPEHPK3PXP", time.Now(), nil), convey.ShouldEqual, "487978") + convey.So(otp.TOTPCodeCustom("JBSWY3DPEHPK3PXP", time.Now(), &otp.Opts{Period: 60}), convey.ShouldEqual, "008395") + convey.So(otp.TOTPCodeCustom("JBSWY3DPEHPK3PXP", time.Now(), &otp.Opts{Period: 60, Digits: 6}), convey.ShouldEqual, "116644") + }) +} + +func TestHOTPCode(t *testing.T) { + convey.Convey("TestHOTPCode", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{"996554", nil}, Times: 1}, + {Values: gomonkey.Params{"602287", nil}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(hotp.GenerateCode, outputs) + defer af.Reset() + convey.So(otp.HOTPCode("JBSWY3DPEHPK3PXP", 1), convey.ShouldEqual, "996554") + convey.So(otp.HOTPCode("JBSWY3DPEHPK3PXP", 2), convey.ShouldEqual, "602287") + }) +} + +func TestHOTPCodeCustom(t *testing.T) { + convey.Convey("TestHOTPCodeCustom", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{"41996554", nil}, Times: 1}, + {Values: gomonkey.Params{"996554", nil}, Times: 1}, + {Values: gomonkey.Params{"88602287", nil}, Times: 1}, + {Values: gomonkey.Params{"602287", nil}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(hotp.GenerateCodeCustom, outputs) + defer af.Reset() + convey.So(otp.HOTPCodeCustom("JBSWY3DPEHPK3PXP", 1, &otp.Opts{Digits: 8}), convey.ShouldEqual, "41996554") + convey.So(otp.HOTPCodeCustom("JBSWY3DPEHPK3PXP", 1, &otp.Opts{Digits: 6}), convey.ShouldEqual, "996554") + convey.So(otp.HOTPCodeCustom("JBSWY3DPEHPK3PXP", 2, &otp.Opts{Digits: 8}), convey.ShouldEqual, "88602287") + convey.So(otp.HOTPCodeCustom("JBSWY3DPEHPK3PXP", 2, &otp.Opts{Digits: 6}), convey.ShouldEqual, "602287") + }) +} + +func TestVerifyTOTP(t *testing.T) { + convey.Convey("TestVerifyTOTP", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{true}, Times: 1}, + {Values: gomonkey.Params{true}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(totp.Validate, outputs) + defer af.Reset() + convey.So(otp.VerifyTOTP("563324", "JBSWY3DPEHPK3PXP"), convey.ShouldBeTrue) + convey.So(otp.VerifyTOTP("487978", "JBSWY3DPEHPK3PXP"), convey.ShouldBeTrue) + }) +} + +func TestVerifyTOTPCustom(t *testing.T) { + convey.Convey("TestVerifyTOTPCustom", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{true, nil}, Times: 1}, + {Values: gomonkey.Params{true, nil}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(totp.ValidateCustom, outputs) + defer af.Reset() + convey.So(otp.VerifyTOTPCustom("563324", "JBSWY3DPEHPK3PXP", time.Now(), nil), convey.ShouldBeTrue) + convey.So(otp.VerifyTOTPCustom("487978", "JBSWY3DPEHPK3PXP", time.Now(), nil), convey.ShouldBeTrue) + }) +} + +func TestVerifyHOTP(t *testing.T) { + convey.Convey("TestVerifyHOTP", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{true}, Times: 1}, + {Values: gomonkey.Params{true}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(hotp.Validate, outputs) + defer af.Reset() + convey.So(otp.VerifyHOTP("996554", 1, "JBSWY3DPEHPK3PXP"), convey.ShouldBeTrue) + convey.So(otp.VerifyHOTP("602287", 2, "JBSWY3DPEHPK3PXP"), convey.ShouldBeTrue) + }) +} + +func TestVerifyHOTPCustom(t *testing.T) { + convey.Convey("TestVerifyHOTPCustom", t, func() { + outputs := []gomonkey.OutputCell{ + {Values: gomonkey.Params{true, nil}, Times: 1}, + {Values: gomonkey.Params{true, nil}, Times: 1}, + } + af := gomonkey.ApplyFuncSeq(hotp.ValidateCustom, outputs) + defer af.Reset() + convey.So(otp.VerifyHOTPCustom("996554", 1, "JBSWY3DPEHPK3PXP", nil), convey.ShouldBeTrue) + convey.So(otp.VerifyHOTPCustom("602287", 2, "JBSWY3DPEHPK3PXP", nil), convey.ShouldBeTrue) + }) +}