diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3138cd4..2e798b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased] MM-DD-YYYY
+## [1.2.0] 10-14-2017
+### Added
+- Mutlifactor Authentication
+
## [1.1.0] 10-11-2017
### Changed
- Many internal functions now return errors instead of panicking
@@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
[1.0.0]: https://github.com/while-loop/lastpass-go/releases/tag/v1.0.0
[1.1.0]: https://github.com/while-loop/lastpass-go/compare/v1.0.0...v1.1.0
-[Unreleased]: https://github.com/while-loop/lastpass-go/compare/v1.1.0...master
+[1.2.0]: https://github.com/while-loop/lastpass-go/compare/v1.1.0...v1.2.0
+[Unreleased]: https://github.com/while-loop/lastpass-go/compare/v1.2.0...master
[comment]: # (Added, Changed, Removed)
diff --git a/README.md b/README.md
index 118e95f..ae305f6 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Features
- Create/Update accounts
- Delete accounts
- Get accounts
+- Multi-factor authentication
Installation
------------
@@ -35,8 +36,20 @@ $ go get github.com/while-loop/lastpass-go
Usage
-----
+[Example Usages](vault_test.go)
+
+```go
+lp, _ := lastpass.New(email, password)
+accs, _ := lp.GetAccounts()
+for _, account := range accs {
+ fmt.Println(account.Username, account.Password)
+}
+```
+
+#### With Multi-factor Auth
+
```go
-lp, _ := lastpass.New(username, password)
+lp, err := New(email, password, WithMultiFactor("5412548"))
accs, _ := lp.GetAccounts()
for _, account := range accs {
fmt.Println(account.Username, account.Password)
@@ -49,7 +62,6 @@ TODO
These are future plans for the project, feel free fork/pr these features
if I don't get to them in time.
-- 2FA login
- Shared groups
- Secured notes
diff --git a/account_test.go b/account_test.go
new file mode 100644
index 0000000..0f5627a
--- /dev/null
+++ b/account_test.go
@@ -0,0 +1,18 @@
+package lastpass
+
+import (
+ "testing"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStringFormat(t *testing.T) {
+ assert.Contains(t, Account{Username: "yoyo@gmail.com"}.String(), "yoyo@gmail.com")
+}
+
+func TestFailEncInvalidKeySize(t *testing.T) {
+ acc := Account{Username: "yoyo@gmail.com"}
+ vals, err := acc.encrypt(s2b("sd"))
+ assert.Nil(t, vals)
+ assert.Contains(t, err.Error(), "invalid key size 2")
+ assert.Contains(t, err.Error(), "username")
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..86f7a0b
--- /dev/null
+++ b/config.go
@@ -0,0 +1,19 @@
+package lastpass
+
+// ConfigFunc allows modification of configurations
+// in the Vault struct
+type ConfigFunc func(opts *ConfigOptions)
+
+// ConfigOptions are config options that
+// set behaviours in Vault.
+// Current supported configs is multi-factor auth.
+type ConfigOptions struct {
+ multiFactor string
+}
+
+// WithMultiFactor adds multi-factor auth to your vault.
+func WithMultiFactor(code string) ConfigFunc {
+ return func(opts *ConfigOptions) {
+ opts.multiFactor = code
+ }
+}
diff --git a/example/credentials.txt.example b/example/credentials.txt.example
deleted file mode 100644
index 41cd875..0000000
--- a/example/credentials.txt.example
+++ /dev/null
@@ -1,2 +0,0 @@
-username@example.com
-password
diff --git a/example/example.go b/example/example.go
deleted file mode 100644
index f873e9b..0000000
--- a/example/example.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "github.com/while-loop/lastpass-go"
- "io/ioutil"
- "log"
- "os"
- "strings"
-)
-
-func main() {
- b, err := ioutil.ReadFile("example/credentials.txt")
- if err != nil {
- log.Fatal(err)
- }
- lines := strings.Split(string(b), "\n")
- username := lines[0]
- password := lines[1]
-
- lp, err := lastpass.New(username, password)
- if err != nil {
- log.Fatal(err)
- }
-
- accs, err := lp.GetAccounts()
- if err != nil {
- log.Fatal(err)
- }
-
- for _, account := range accs {
- json.NewEncoder(os.Stdout).Encode(account)
- fmt.Println()
- }
-}
diff --git a/fetcher.go b/fetcher.go
index f7f4741..5316526 100644
--- a/fetcher.go
+++ b/fetcher.go
@@ -13,6 +13,8 @@ import (
"net/http/cookiejar"
"net/url"
"strconv"
+ "github.com/pkg/errors"
+ "bytes"
)
type blob struct {
@@ -28,57 +30,93 @@ type session struct {
key []byte
}
-func buildLastPassURL(path string) *url.URL {
- return &url.URL{
- Scheme: "https",
- Host: "lastpass.com",
- Path: path,
- }
-}
+const (
+ loginPage = "login.php"
+ iterationsPage = "iterations.php"
+ getAccountsPage = "getaccts.php"
+
+)
var (
- ErrInvalidPassword = fmt.Errorf("invalid username or password")
+ ErrInvalidPassword = fmt.Errorf("invalid password")
+ ErrInvalidEmail = fmt.Errorf("invalid username or password")
+ ErrInvalidGoogleAuthCode = fmt.Errorf("googleauthfailed")
+ ErrInvalidYubiKey = fmt.Errorf("yubikeyrestricted")
)
-func login(username, password string) (*session, error) {
+func login(username, password string, multiFactor string) (*session, error) {
iterationCount, err := requestIterationCount(username)
if err != nil {
return nil, err
}
- return make_session(username, password, iterationCount)
+ return make_session(username, password, iterationCount, multiFactor)
}
-func make_session(username, password string, iterationCount int) (*session, error) {
+func make_session(username, password string, iterationCount int, multiFactor string) (*session, error) {
cookieJar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
- client := &http.Client{
- Jar: cookieJar,
+ client := newClient(cookieJar)
+
+ vals := url.Values{
+ "method": []string{"mobile"},
+ "web": []string{"1"},
+ "xml": []string{"1"},
+ "username": []string{username},
+ "hash": []string{string(makeHash(username, password, iterationCount))},
+ "iterations": []string{fmt.Sprint(iterationCount)},
+ }
+ if multiFactor != "" {
+ vals.Set("otp", multiFactor)
}
- res, err := client.PostForm(
- buildLastPassURL("login.php").String(),
- url.Values{
- "method": []string{"mobile"},
- "web": []string{"1"},
- "xml": []string{"1"},
- "username": []string{username},
- "hash": []string{string(makeHash(username, password, iterationCount))},
- "iterations": []string{fmt.Sprint(iterationCount)},
- })
+
+ res, err := client.PostForm(buildLastPassURL(loginPage).String(), vals)
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "unable to reach LastPass servers")
}
+
defer res.Body.Close()
var response struct {
SessionId string `xml:"sessionid,attr"`
Token string `xml:"token,attr"`
+ ErrResp *struct {
+ AttrAllowmultifactortrust string `xml:" allowmultifactortrust,attr" json:",omitempty"`
+ AttrCause string `xml:" cause,attr" json:",omitempty"`
+ AttrHidedisable string `xml:" hidedisable,attr" json:",omitempty"`
+ AttrMessage string `xml:" message,attr" json:",omitempty"`
+ AttrTempuid string `xml:" tempuid,attr" json:",omitempty"`
+ AttrTrustexpired string `xml:" trustexpired,attr" json:",omitempty"`
+ AttrTrustlabel string `xml:" trustlabel,attr" json:",omitempty"`
+ } `xml:" error,omitempty" json:"error,omitempty"`
+ }
+
+ // read to bytes for debugging
+ b, err := ioutil.ReadAll(res.Body)
+ if err != nil && err != io.EOF {
+ return nil, err
}
- err = xml.NewDecoder(res.Body).Decode(&response)
+
+ err = xml.NewDecoder(bytes.NewReader(b)).Decode(&response)
if err != nil {
return nil, err
}
+ if response.ErrResp != nil {
+ switch response.ErrResp.AttrCause {
+ case "googleauthfailed", "googleauthrequired":
+ return nil, ErrInvalidGoogleAuthCode
+ case "unknownpassword":
+ return nil, ErrInvalidPassword
+ case "yubikeyrestricted":
+ return nil, ErrInvalidYubiKey
+ case "unknownemail":
+ return nil, ErrInvalidEmail
+ default:
+ return nil, fmt.Errorf("%s", response.ErrResp.AttrMessage)
+ }
+ }
+
key := makeKey(username, password, iterationCount)
return &session{response.SessionId,
response.Token,
@@ -89,30 +127,32 @@ func make_session(username, password string, iterationCount int) (*session, erro
}
func fetch(s *session) (*blob, error) {
- u := buildLastPassURL("getaccts.php")
+ u := buildLastPassURL(getAccountsPage)
u.RawQuery = (&url.Values{
"mobile": []string{"1"},
"b64": []string{"1"},
"hash": []string{"0.0"},
"PHPSESSID": []string{s.id},
}).Encode()
- client := &http.Client{
- Jar: s.cookieJar,
- }
+
+ client := newClient(s.cookieJar)
+
res, err := client.Get(u.String())
if err != nil {
return nil, err
}
defer res.Body.Close()
- if res.StatusCode == http.StatusForbidden {
- return nil, ErrInvalidPassword
- }
-
b, err := ioutil.ReadAll(res.Body)
if err != nil && err != io.EOF {
return nil, err
}
+
+ //fmt.Println(string(b))
+ if res.StatusCode == http.StatusForbidden {
+ return nil, ErrInvalidPassword
+ }
+
b, err = base64.StdEncoding.DecodeString(string(b))
if err != nil {
return nil, err
@@ -126,11 +166,7 @@ func post(postUrl *url.URL, s *session, values *url.Values) (string, error) {
}
values.Set("token", string(s.token))
- // TODO fix encoding b64
- // 2017/10/09 11:46:30
- client := &http.Client{
- Jar: s.cookieJar,
- }
+ client := newClient(s.cookieJar)
res, err := client.PostForm(postUrl.String(), *values)
if err != nil {
@@ -150,19 +186,9 @@ func post(postUrl *url.URL, s *session, values *url.Values) (string, error) {
return string(b), nil
}
-func encodeValues(values *url.Values) *url.Values {
- newValues := &url.Values{}
- for key, val := range *values {
- for _, v := range val {
- newValues.Add(key, base64.StdEncoding.EncodeToString(s2b(v)))
- }
- }
- return newValues
-}
-
func requestIterationCount(username string) (int, error) {
res, err := http.DefaultClient.PostForm(
- buildLastPassURL("iterations.php").String(),
+ buildLastPassURL(iterationsPage).String(),
url.Values{
"email": []string{username},
})
@@ -197,3 +223,18 @@ func makeHash(username, password string, iterationCount int) []byte {
}
return lcrypt.EncodeHex(pbkdf2.Key([]byte(key), []byte(password), 1, 32, sha256.New))
}
+
+// used to mock lastpass responses
+var newClient = func(jar http.CookieJar) *http.Client {
+ return &http.Client{
+ Jar: jar,
+ }
+}
+
+func buildLastPassURL(path string) *url.URL {
+ return &url.URL{
+ Scheme: "https",
+ Host: "lastpass.com",
+ Path: path,
+ }
+}
\ No newline at end of file
diff --git a/fetcher_test.go b/fetcher_test.go
new file mode 100644
index 0000000..693446d
--- /dev/null
+++ b/fetcher_test.go
@@ -0,0 +1,19 @@
+package lastpass
+
+import (
+ "testing"
+ "github.com/while-loop/lastpass-go/internal/crypt"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIterationCount1(t *testing.T) {
+ expected := []byte("65333739643937326333656235393537396162653338363464383530623566353439313135343461646661326461663966623533633035643330636463393835")
+ hash := crypt.EncodeHex(makeHash("username", "password", 1))
+ assert.Equal(t, expected, hash)
+}
+
+func TestIterationCount2(t *testing.T) {
+ expected := []byte("38363361663762326636373131386162643139623936313265343233313661363033666664646437666330623730353334313936356331653839643864333565")
+ hash := crypt.EncodeHex(makeHash("username", "password", 2))
+ assert.Equal(t, expected, hash)
+}
diff --git a/internal/crypt/aes.go b/internal/crypt/aes.go
index c04ce64..68fd358 100644
--- a/internal/crypt/aes.go
+++ b/internal/crypt/aes.go
@@ -84,7 +84,7 @@ func encrypt_aes256_cbc(plaintext, iv, key []byte) ([]byte, error) {
func Encrypt_aes256_cbc_base64(plaintext, key []byte) ([]byte, error) {
iv, _, err := getIv()
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "failed to get iv to encode aes256")
}
ctext, err := encrypt_aes256_cbc_base64(plaintext, iv, key)
diff --git a/internal/crypt/encoding_test.go b/internal/crypt/encoding_test.go
index 22576bf..9275eab 100644
--- a/internal/crypt/encoding_test.go
+++ b/internal/crypt/encoding_test.go
@@ -52,3 +52,15 @@ func TestDecodeBase64(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "gg no re too ez", string(decoded))
}
+
+func TestDecodeBase64WithInvalidChars(t *testing.T) {
+ decoded, err := DecodeBase64([]byte("-z*/asdag"))
+ assert.Nil(t, decoded)
+ assert.Error(t, err)
+}
+
+func TestDecodeHexWithInvalidChars(t *testing.T) {
+ decoded, err := DecodeHex([]byte("-z*/asdag"))
+ assert.Nil(t, decoded)
+ assert.Error(t, err)
+}
diff --git a/vault.go b/vault.go
index 46eeaf6..130a547 100644
--- a/vault.go
+++ b/vault.go
@@ -17,11 +17,17 @@ var (
type Vault struct {
sesh *session
email string
+ opts *ConfigOptions
}
// New logs into LastPass and returns a new Vault
-func New(email, password string) (*Vault, error) {
- session, err := login(email, password)
+func New(email, password string, opts ...ConfigFunc) (*Vault, error) {
+ configOpts := new(ConfigOptions)
+ for _, opt := range opts {
+ opt(configOpts)
+ }
+
+ session, err := login(email, password, configOpts.multiFactor)
if err != nil {
return nil, err
}
@@ -33,7 +39,7 @@ func New(email, password string) (*Vault, error) {
return nil, err
}
- return &Vault{sesh: session, email: email}, nil
+ return &Vault{sesh: session, email: email, opts: configOpts}, nil
}
// Email returns the email associated with the vault
diff --git a/vault_test.go b/vault_test.go
index 5b52ccb..f5a6280 100644
--- a/vault_test.go
+++ b/vault_test.go
@@ -4,6 +4,7 @@ import (
"github.com/stretchr/testify/assert"
"os"
"testing"
+ "gopkg.in/jarcoal/httpmock.v1"
)
const (
@@ -30,7 +31,13 @@ var config = struct {
func TestInvalidEmail(t *testing.T) {
lp, err := New("fakeemail@hotmail.com", "fakepassword")
assert.Nil(t, lp)
- assert.Equal(t, ErrInvalidPassword, err)
+ assert.EqualError(t, err, ErrInvalidEmail.Error())
+}
+
+func TestInvalidPassword(t *testing.T) {
+ lp, err := New(config.email, "fakepassword")
+ assert.Nil(t, lp)
+ assert.EqualError(t, err, ErrInvalidPassword.Error())
}
func TestCRUD(t *testing.T) {
@@ -71,6 +78,51 @@ func TestCRUD(t *testing.T) {
assert.Empty(t, actuals)
}
+func TestIncorrectGoogleAuthCode(t *testing.T) {
+ if os.Getenv("MOCK_LP") != "" {
+ t.Logf("running %s in mock mode", t.Name())
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ data := ``
+ httpmock.RegisterResponder("POST", buildLastPassURL(iterationsPage).String(), httpmock.NewStringResponder(200, "5461"))
+ httpmock.RegisterResponder("POST", buildLastPassURL(loginPage).String(), httpmock.NewStringResponder(200, data))
+ }
+
+ lp, err := New("zqg45101@loaoa.com", "qwerty123", WithMultiFactor("-3"))
+ assert.Nil(t, lp)
+ assert.EqualError(t, err, ErrInvalidGoogleAuthCode.Error())
+}
+
+func TestNoGoogleAuthCodeGiven(t *testing.T) {
+ if os.Getenv("MOCK_LP") != "" {
+ t.Logf("running %s in mock mode", t.Name())
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ data := ``
+ httpmock.RegisterResponder("POST", buildLastPassURL(iterationsPage).String(), httpmock.NewStringResponder(200, "5461"))
+ httpmock.RegisterResponder("POST", buildLastPassURL(loginPage).String(), httpmock.NewStringResponder(200, data))
+ }
+
+ lp, err := New("ocr94395@loaoa.com", "qwerty123")
+ assert.Nil(t, lp)
+ assert.EqualError(t, err, ErrInvalidGoogleAuthCode.Error())
+}
+func TestInvalidYubiKey(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ t.Logf("running %s in mock mode", t.Name())
+
+ data := ``
+ httpmock.RegisterResponder("POST", buildLastPassURL(iterationsPage).String(), httpmock.NewStringResponder(200, "5461"))
+ httpmock.RegisterResponder("POST", buildLastPassURL(loginPage).String(), httpmock.NewStringResponder(200, data))
+
+ lp, err := New("zqg4s5101@loaoa.com", "qwerty123")
+ assert.Nil(t, lp)
+ assert.EqualError(t, err, ErrInvalidYubiKey.Error())
+}
+
func mustDeleteAccounts(lp *Vault) {
accs, err := lp.GetAccounts()
if err != nil {