Skip to content

Commit

Permalink
Merge pull request #4 from KEINOS/feat-issue3-qr-code
Browse files Browse the repository at this point in the history
feat: QR Code image support
fix #3
  • Loading branch information
KEINOS authored Sep 6, 2022
2 parents 04ee7e3 + a1c1622 commit 2fdc4ee
Show file tree
Hide file tree
Showing 17 changed files with 293 additions and 15 deletions.
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,27 @@ func Example() {
// Basic methods of totp.Key object
// --------------------------------------------------

// Generate the current passcode
// Generate the current passcode.
passcode, err := key.PassCode()

// Validate the received passcode
// Validate the received passcode.
ok, err := key.Validate(passcode)

// Get the secret key in PEM format
// Get 100x100 px image of QR code as PNG byte data.
// FixLevelDefault is the 15% of error correction.
qrCodeObj, err := key.QRCode(totp.FixLevelDefault)
qrCodePNG, err := qrCodeObj.PNG(100, 100)

// Get the secret key in PEM format text.
pemKey, err := key.PEM()

// Get the secret key in TOTP URI format
// Get the secret key in TOTP URI format string.
uriKey := key.URI()

// Get the secret value in Base32 format
// Get the secret value in Base32 format string.
base32Key := key.Secret.Base32()

// Get the secret value in Base62 format
// Get the secret value in Base62 format string.
base62Key := key.Secret.Base62()
```

Expand All @@ -95,6 +100,7 @@ base62Key := key.Secret.Base62()
Any Pull-Request for improvement is welcome!

- Branch to PR: `main`
- [CONTRIBUTING.md](./.github/CONTRIBUTING.md)
- [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)

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/KEINOS/go-totp
go 1.15

require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.3.0
github.com/stretchr/testify v1.8.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
66 changes: 66 additions & 0 deletions totp/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,72 @@ func ExampleKey() {
// AccountName: [email protected]
}

//nolint:funlen // length is 62 lines long but leave it as is due to embedded example
func ExampleKey_QRCode() {
origin := "otpauth://totp/Example.com:[email protected]?algorithm=SHA1&" +
"digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3"

// Create a new Key object from a URI
key, err := totp.GenerateKeyURI(origin)
if err != nil {
log.Fatal(err)
}

// Create QRCode object
imgQRCode, err := key.QRCode(totp.FixLevelDefault)
if err != nil {
log.Fatal(err)
}

// Get PNG image in bytes
pngImage, err := imgQRCode.PNG(100, 100)
if err != nil {
log.Fatal(err)
}

actual := fmt.Sprintf("%x", pngImage)
expect := `89504e470d0a1a0a0000000d4948445200000064000000641000000000051` +
`916cb0000040549444154789ce45ced6edb400c9387bcff2b67180a2f324d5272ff` +
`95d59f35f7691f4f12a9047bbddf1561afaae3d0dde76bf631bdeddfdffd5f370fd` +
`7c5f99b7df473fef1eff973ecf5f50fbb60ec74b01dfbce93eba7cce6633fae778e` +
`617dfc39d310a90101e503bdbff7f55357779ba1727e763ec69f231191c9f094ce1` +
`3c5c88363b17f4217db7709e23722c20c917077daa1721aa2be4fd789886cdebe9f` +
`6e3796a927b4703f8c722ad7f0e74c43c4f198d3d489bb2c5dc28fa6f90a19fd9c4` +
`9883c21f2d33d7ec68fb83994b4c52072bcdf8e1339edd1e76c4f7d42df4536bf5e` +
`1a2235a8b6291f4c116c52932c32b13d35a28988d4a033f054a6d355e3e5a318e49` +
`53f7eda9210f9ffa7f095d3143762f3a7317d4d75f2ac4d71b83c443668a87c33f1` +
`afefa08aa67ceef339061150882ebef77665937e983489da57a1f1b12444b675277` +
`7f771ceac1f7ccd6bc316aef5b32444746cf6a75ea6eac1906111ab4424ab07dc2f` +
`2f6a3da9064eda5ef988d335389fadc9d6ba5a0c22c7f5fd9c9ea8413b383e44b71` +
`e1057be837d79510b4dc56f15a9b67fe33ab8966b57d59bcffa31881085e878ce94` +
`6f9efa85426ce387578b41c47c3fc232b8d21e7d8eb29e5f7a5b01b25b34aee8262` +
`1a2388dd3112ab239a5c8d639cd2947e74b15f93dfbe6edcb54fe948a448ec4d6c1` +
`f570bee263773f4942a408b364a7dfc74cd590d3b61a67529fb8ce7dbf2444d4292` +
`8fea3fa3677ba067ee5f290afac2421b2b9df657247895f3738fec5c6d7c0a7fc33` +
`2621c24cc575e507cecfd867c59291b7b95ac115bd34442625876dea2ebb0c8fa7e` +
`b7899aa9c689f4a4264d20865eeee5431615ac5b16336cead9b19b55cd564527c4e` +
`6fa87bddc72aff62151b857a586637954616955c1564f22d77baca7fdc3e95f93df` +
`be02365f4b33aedd354b545b5b1bdd83adc921029f1d6ea2ebbaa8932a5f19d8264` +
`63b5928d4184fc824e2941759fb7999d459f890de33e257d2c0691d7acbd550c67a` +
`69800e366ee84fbbc69cf2f4b42a408ffaf21533b1dcea2df661eeea9d6e53e1983` +
`c8717f371769b0ed0903c6cf133a684abf87b15f52fb75bc0bc73afdcdc697d1e3b` +
`8a68aa01c993444ca5416ddbd76f7d621d7f798b4067e663e1c94d9e157a6b560a4` +
`45eeaa53747d5d954fb62c5a5b1222b31edee9f05a565ea63536ebe0b83c1fb1430` +
`ca3ddf84e5fc75544702fa791ea7653621059fc9f0f4f3484e24f8a2594f9b6d865` +
`f5fbb82444eac1f77c5d13e058cf4e791f8e719ccd7b731a22356873773fd538869` +
`ccb190e15c5bb2a568f4ca62257b72d3b9ea2e471785fc1bd33a3d664131a38ae8c` +
`26a9bafb113b75c584d197c27ca4d57e6f5ddfc80d6abc9a37712dd6c77d330d116` +
`51839d4bd773ae3b29dc8d893f6708ab2b2aaf10b3df233ec6f000000ffffa0404e` +
`fb1e0ab59a0000000049454e44ae426082`

// Assert equal image
if expect == actual {
fmt.Println("OK")
}

// Output: OK
}

func ExampleKey_PEM() {
origin := "otpauth://totp/Example.com:[email protected]?algorithm=SHA1&" +
"digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3"
Expand Down
39 changes: 39 additions & 0 deletions totp/fix_level.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package totp

import "github.com/boombuler/barcode/qr"

// FixLevel is the error correction level for QR code. Use `FixLevel*` constants
// to set the level.
type FixLevel byte

// Error correction level for QR code.
const (
// FixLevel30 is the highest level of error correction for QR codes, capable
// of recovering 30% of the data.
FixLevel30 = FixLevel(qr.H)
// FixLevel25 is a qualified error correction level for QR codes, which can
// recover 25% of the data.
FixLevel25 = FixLevel(qr.Q)
// FixLevel15 is a medium error correction level for QR codes, capable of
// recovering 15% of the data.
FixLevel15 = FixLevel(qr.M)
// FixLevel7 is the lowest level of error correction for QR codes and can
// recover 7% of the data.
FixLevel7 = FixLevel(qr.L)
// FixLevelDefault is the default error correction level for QR codes.
// Currently set to FixLevel15.
FixLevelDefault = FixLevel15
)

func (f FixLevel) qrFixLevel() qr.ErrorCorrectionLevel {
return qr.ErrorCorrectionLevel(f)
}

func (f FixLevel) isValid() bool {
switch f {
case FixLevel30, FixLevel25, FixLevel15, FixLevel7:
return true
default:
return false
}
}
17 changes: 16 additions & 1 deletion totp/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const BlockTypeTOTP = "TOTP SECRET KEY"

// Key is a struct that holds the TOTP secret and its options.
type Key struct {
Options Options // Options to be stored.
Secret Secret // The secret key.
Options Options // Options to be stored.
}

// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -186,6 +186,21 @@ func (k *Key) PEM() (string, error) {
return string(out), nil
}

// QRCode returns a QR code image of a specified width and height, suitable for
// registering a user's TOTP URI with many clients, such as Google-Authenticator.
func (k *Key) QRCode(fixLevel FixLevel) (*QRCode, error) {
if !fixLevel.isValid() {
return nil, errors.Errorf("unsupported fix level: %v", fixLevel)
}

qrCode := &QRCode{
URI: URI(k.URI()),
Level: fixLevel,
}

return qrCode, nil
}

// URI returns the key in OTP URI format.
//
// It re-generates the URI from the values stored in the Key object and will not
Expand Down
17 changes: 17 additions & 0 deletions totp/key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,23 @@ func TestGenerateKeyURI_error_msg(t *testing.T) {
require.Contains(t, err.Error(), "failed to generate key")
}

// ----------------------------------------------------------------------------
// Key.QRCode()
// ----------------------------------------------------------------------------

func TestKey_QRCode_bad_fix_level(t *testing.T) {
t.Parallel()

//nolint:exhaustruct // missing fields are not required for this test
key := Key{}

imgQRCode, err := key.QRCode(FixLevel(100))

require.Error(t, err, "unsupported fix level should return error")
require.Contains(t, err.Error(), "unsupported fix level: 100")
require.Nil(t, imgQRCode)
}

// ----------------------------------------------------------------------------
// Key.PEM()
// ----------------------------------------------------------------------------
Expand Down
File renamed without changes.
59 changes: 59 additions & 0 deletions totp/qrcode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package totp

import (
"bytes"
"image"
"image/png"

"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/pkg/errors"
)

// QRCode is a struct that holds the information to create QR code image.
type QRCode struct {
URI URI // URI object to be encoded to QR code image.
Level FixLevel // Level is the error correction level for the QR code.
}

// Image returns an image.Image object of the QR code. Minimum width and height
// is 49x49.
func (q *QRCode) Image(width, height int) (image.Image, error) {
uri := q.URI.String()

qrCode, err := qr.Encode(uri, q.Level.qrFixLevel(), qr.Auto)
if err != nil || uri == "" {
if uri == "" {
err = errors.New("empty URI")
}

return nil, errors.Wrap(err, "failed to encode URI to QR code")
}

qrCode, err = barcode.Scale(qrCode, width, height)
if err != nil {
return nil, errors.Wrap(err, "failed to scale QR code")
}

return qrCode, nil
}

//nolint:gochecknoglobals // allow private global variable to mock during tests
var pngEncode = png.Encode

// PNG returns a PNG image of the QR code in bytes. Minimum width and height
// is 49x49.
func (q *QRCode) PNG(width, height int) ([]byte, error) {
img, err := q.Image(width, height)
if err != nil {
return nil, errors.Wrap(err, "failed to generate QR code PNG image")
}

var buf bytes.Buffer

if err := pngEncode(&buf, img); err != nil {
return nil, errors.Wrap(err, "failed to encode QR code image to PNG")
}

return buf.Bytes(), nil
}
73 changes: 73 additions & 0 deletions totp/qrcode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package totp

import (
"image"
"io"
"testing"

"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)

func TestQRCode_PNG_empty_uri(t *testing.T) {
t.Parallel()

qr := QRCode{
URI: URI(""),
Level: FixLevelDefault,
}

img, err := qr.PNG(100, 100)

require.Error(t, err, "empty URI should return error")
require.Contains(t, err.Error(), "failed to encode URI to QR code: empty URI")
require.Nil(t, img, "it should be nil on error")
}

//nolint:paralleltest // disable parallel test due to monkey patching during test
func TestQRCode_PNG_fail_encoding(t *testing.T) {
origin := "otpauth://totp/Example.com:[email protected]?algorithm=SHA1&" +
"digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3"

qrCode := QRCode{
URI: URI(origin),
Level: FixLevelDefault,
}

// Backup and defer restore
oldPNGEncode := pngEncode
defer func() {
pngEncode = oldPNGEncode
}()

// Mock pngEncode to force return error
pngEncode = func(w io.Writer, m image.Image) error {
return errors.New("forced error")
}

img, err := qrCode.PNG(100, 100)

require.Error(t, err, "failed to encode QR code to PNG should return error")
require.Contains(t, err.Error(), "failed to encode QR code image to PNG")
require.Contains(t, err.Error(), "forced error")
require.Nil(t, img, "it should be nil on error")
}

func TestQRCode_Image_failed_to_scale(t *testing.T) {
t.Parallel()

origin := "otpauth://totp/Example.com:[email protected]?algorithm=SHA1&" +
"digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3"

qr := QRCode{
URI: URI(origin),
Level: FixLevelDefault,
}

img, err := qr.Image(0, 0)

require.Error(t, err, "failed to scale QR code should return error")
require.Contains(t, err.Error(), "failed to scale QR code")
require.Contains(t, err.Error(), "can not scale barcode to an image smaller than 49x49")
require.Nil(t, img, "it should be nil on error")
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
16 changes: 8 additions & 8 deletions totp/key_uri_test.go → totp/uri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,16 @@ func TestURI_malformed_uri(t *testing.T) {

// Methods should return empty string as well
for _, test := range []struct {
name string
function func() string
name string
}{
{"Scheme", uri.Scheme},
{"Host", uri.Host},
{"Issuer", uri.Issuer},
{"AccountName", uri.AccountName},
{"Secret", uri.Secret().String},
{"Algorithm", uri.Algorithm},
{"IssuerFromPath", uri.IssuerFromPath},
{name: "Scheme", function: uri.Scheme},
{name: "Host", function: uri.Host},
{name: "Issuer", function: uri.Issuer},
{name: "AccountName", function: uri.AccountName},
{name: "Secret", function: uri.Secret().String},
{name: "Algorithm", function: uri.Algorithm},
{name: "IssuerFromPath", function: uri.IssuerFromPath},
} {
got := test.function()

Expand Down

0 comments on commit 2fdc4ee

Please sign in to comment.