diff --git a/go.mod b/go.mod
index 56e1951..a325ddb 100644
--- a/go.mod
+++ b/go.mod
@@ -8,4 +8,5 @@ require (
github.com/google/uuid v1.1.1
github.com/joho/godotenv v1.3.0
github.com/mailgun/mailgun-go/v3 v3.6.4
+ github.com/mileusna/useragent v1.0.2
)
diff --git a/go.sum b/go.sum
index e3c4c0e..bf5a355 100644
--- a/go.sum
+++ b/go.sum
@@ -20,5 +20,7 @@ github.com/mailgun/mailgun-go/v3 v3.6.4 h1:+cvbZRgLSHivbz/w1iWLmxVl6Bqf4geD2D7QM
github.com/mailgun/mailgun-go/v3 v3.6.4/go.mod h1:ZjVnH8S0dR2BLjvkZc/rxwerdcirzlA12LQDuGAadR0=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329 h1:2gxZ0XQIU/5z3Z3bUBu+FXuk2pFbkN6tcwi/pjyaDic=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mileusna/useragent v1.0.2 h1:DgVKtiPnjxlb73z9bCwgdUvU2nQNQ97uhgfO8l9uz/w=
+github.com/mileusna/useragent v1.0.2/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
diff --git a/mockid/hashcash.go b/mockid/hashcash.go
index 4505313..99eb2ae 100644
--- a/mockid/hashcash.go
+++ b/mockid/hashcash.go
@@ -28,7 +28,7 @@ func NewHashcash(sub string, exp time.Time) *hashcash.Hashcash {
var ErrNotFound = errors.New("not found")
-func UseHashcash(hc string) error {
+func UseHashcash(hc, sub string) error {
phony, err := hashcash.Parse(hc)
if nil != err {
return err
@@ -42,7 +42,7 @@ func UseHashcash(hc string) error {
mccopy := *mccoy
mccopy.Solution = phony.Solution
- if err := mccopy.Verify("*"); nil != err {
+ if err := mccopy.Verify(sub); nil != err {
return err
}
@@ -60,7 +60,7 @@ func requireHashcash(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hc := r.Header.Get("Hashcash")
_ = issueHashcash(w, r)
- if err := UseHashcash(hc); nil != err {
+ if err := UseHashcash(hc, r.Host); nil != err {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
diff --git a/mockid/mockid.go b/mockid/mockid.go
index 9879c9e..0e8674d 100644
--- a/mockid/mockid.go
+++ b/mockid/mockid.go
@@ -34,9 +34,9 @@ type PublicJWK struct {
type KVDB interface {
Load(key interface{}) (value interface{}, ok bool, err error)
- Store(key interface{}) (value interface{}, err error)
+ Store(key interface{}, value interface{}) (err error)
Delete(key interface{}) (err error)
- vacuum() (err error)
+ Vacuum() (err error)
}
type InspectableToken struct {
diff --git a/mockid/route.go b/mockid/route.go
index 5dd6127..292d91e 100644
--- a/mockid/route.go
+++ b/mockid/route.go
@@ -4,6 +4,7 @@ import (
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
+ "crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
@@ -17,11 +18,14 @@ import (
"strings"
"time"
+ "git.coolaj86.com/coolaj86/go-mockid/kvdb"
"git.coolaj86.com/coolaj86/go-mockid/mockid/api"
"git.coolaj86.com/coolaj86/go-mockid/xkeypairs"
"git.rootprojects.org/root/keypairs"
"git.rootprojects.org/root/keypairs/keyfetch"
+
"github.com/google/uuid"
+ ua "github.com/mileusna/useragent"
)
type HTTPResponse struct {
@@ -29,12 +33,36 @@ type HTTPResponse struct {
Success bool `json:"success"`
}
+type TokenResponse struct {
+ Receipt string `json:"receipt"`
+ HTTPResponse
+}
+
+type OTPResponse struct {
+ OTP
+ HTTPResponse
+}
+
+var tokenPrefix string
+var contactPrefix string
+
// Route returns an HTTP Mux containing the full API
func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
Init()
+ contactKV := kvdb.KVDB{
+ Prefix: jwksPrefix + "/contacts",
+ Ext: "eml.json",
+ }
+
// TODO get from main()
- tokenPrefix = jwksPrefix
+ tokenPrefix = jwksPrefix + "/tokens"
+ contactPrefix = jwksPrefix + "/contacts"
+ for _, pre := range []string{tokenPrefix, contactPrefix} {
+ if err := os.MkdirAll(pre, 0750); nil != err {
+ panic(err)
+ }
+ }
pubkey := keypairs.NewPublicKey(privkey.Public())
http.HandleFunc("/api/new-hashcash", func(w http.ResponseWriter, r *http.Request) {
@@ -51,8 +79,8 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
w.Header().Set("Expires", time.Now().Format(http.TimeFormat))
w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store")
w.Header().Set("Pragma", "no-cache")
+ // add reasonable security options
w.Header().Set("Strict-Transport-Security", "max-age=604800")
-
w.Header().Set("X-Frame-Options", "DENY")
h := issueHashcash(w, r)
@@ -60,6 +88,118 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
w.Write(b)
})
+ http.HandleFunc("/api/authn/meta", func(w http.ResponseWriter, r *http.Request) {
+ if "GET" != r.Method {
+ http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ query := r.URL.Query()
+ contact := strings.Replace(strings.TrimPrefix(query.Get("contact"), "mailto:"), " ", "+", -1)
+ if "" == contact {
+ fmt.Println("got here 3a")
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ b, _ := json.Marshal(&HTTPResponse{
+ Error: "missing require query parameter 'contact'",
+ })
+ w.Write(b)
+ return
+ }
+
+ _, ok, err := contactKV.Load(contact)
+ if nil != err {
+ fmt.Println("got here 3b")
+ fmt.Fprintf(os.Stderr, "bad things:", err.Error())
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ if ok {
+ b, _ := json.Marshal(&HTTPResponse{
+ Success: true,
+ })
+ w.Write(b)
+ return
+ }
+
+ b, _ := json.Marshal(&HTTPResponse{
+ Error: "not found",
+ })
+ w.Write(b)
+ })
+
+ http.HandleFunc("/api/authn/verify", func(w http.ResponseWriter, r *http.Request) {
+ baseURL := getBaseURL(r)
+ query := r.URL.Query()
+ contact := strings.Replace(strings.TrimPrefix(query.Get("contact"), "mailto:"), " ", "+", -1)
+ fmt.Println("contact:", contact)
+ addr := strings.Split(r.RemoteAddr, ":")[0]
+ receipt, err := startVerification(
+ baseURL,
+ contact,
+ r.Header.Get("User-Agent"),
+ addr,
+ )
+
+ if nil != err {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ msg, _ := json.Marshal(err.Error())
+ fmt.Fprintf(w, `{"error":%s}`+"\n", msg)
+ return
+ }
+
+ b, _ := json.Marshal(&TokenResponse{
+ HTTPResponse: HTTPResponse{Success: true},
+ Receipt: receipt,
+ })
+ w.Write(b)
+ })
+
+ http.HandleFunc("/api/authn/consume", func(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+ otpSecret := query.Get("secret")
+ secret, b64err := base64.RawURLEncoding.DecodeString(otpSecret)
+ receipt := query.Get("receipt")
+ fmt.Println("secret:", otpSecret, secret, b64err)
+
+ if (0 == len(secret) || nil != b64err) && "" == receipt {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ msg, _ := json.Marshal("missing token secret and/or token receipt")
+ fmt.Fprintf(w, `{"error":%s}`+"\n", msg)
+ return
+ }
+
+ addr := strings.Split(r.RemoteAddr, ":")[0]
+ agent := r.Header.Get("User-Agent")
+ var otp *OTP
+ var err error
+ if 0 != len(secret) && nil == b64err {
+ if "" != receipt {
+ _, rcpt := hashOTPSecret(secret)
+ if rcpt != receipt {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ fmt.Fprintf(w, "%s", "otp secret and receipt do not match")
+ return
+ }
+ }
+ otp, err = consumeOTPSecret("TODO", secret, agent, addr)
+ } else if 0 != len(receipt) {
+ otp, err = consumeOTPReceipt("TODO", receipt, agent, addr)
+ }
+ if nil != err {
+ // TODO propagate error types
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ fmt.Fprintf(w, "%s", err)
+ return
+ }
+
+ b, _ := json.Marshal(&OTPResponse{
+ HTTPResponse: HTTPResponse{Success: true},
+ OTP: *otp,
+ })
+ w.Write(b)
+ })
+
http.HandleFunc("/api/new-nonce", requireHashcash(func(w http.ResponseWriter, r *http.Request) {
indexURL := getBaseURL(r) + "/api/directory"
w.Header().Set("Link", "<"+indexURL+">;rel=\"index\"")
@@ -77,7 +217,7 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
}))
http.HandleFunc("/api/test-hashcash", func(w http.ResponseWriter, r *http.Request) {
- if err := UseHashcash(r.Header.Get("Hashcash")); nil != err {
+ if err := UseHashcash(r.Header.Get("Hashcash"), r.Host); nil != err {
b, _ := json.Marshal(&HTTPResponse{
Error: err.Error(),
})
@@ -159,14 +299,21 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
}
baseURL := getBaseURL(r)
- if err := startVerification(baseURL, contact); nil != err {
+ addr := strings.Split(r.RemoteAddr, ":")[0]
+ receipt, err := startVerification(
+ baseURL,
+ contact,
+ r.Header.Get("User-Agent"),
+ addr,
+ )
+ if nil != err {
http.Error(w, "Bad Request", http.StatusBadRequest)
msg, _ := json.Marshal(err.Error())
fmt.Fprintf(w, `{"error":%s}`+"\n", msg)
return
}
- fmt.Fprintf(w, `{ "success": true, "error": "" }%s`, "\n")
+ fmt.Fprintf(w, `{ "success": true, "error": "", "receipt":, "%s" }%s`, receipt, "\n")
})
// TODO use chi
@@ -176,9 +323,14 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
http.Error(w, "invalid url path", http.StatusBadRequest)
return
}
- token := parts[2]
-
- if err := checkOTP(token); nil != err {
+ secret, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if nil != err {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ fmt.Fprintf(w, "%s", err)
+ return
+ }
+ addr := strings.Split(r.RemoteAddr, ":")[0]
+ if _, err := consumeOTPSecret("TODO", secret, r.Header.Get("User-Agent"), addr); nil != err {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
fmt.Fprintf(w, "%s", err)
return
@@ -420,75 +572,165 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
return http.DefaultServeMux
}
-var tokenPrefix string
+type OTP struct {
+ //Attempts int `json:"attempts"`
+ CreatedAt time.Time `json:"created_at"`
+ Email string `json:"email"`
+ ReceiptUA string `json:"receipt_agent"`
+ ReceiptIP string `json:"receipt_addr"`
+ ReceiptUsed time.Time `json:"receipt_used"`
+ SecretUA string `json:"secret_agent"`
+ SecretIP string `json:"secret_addr"`
+ SecretUsed time.Time `json:"secret_used"`
+}
-func newOTP() (string, error) {
- rnd, err := uuid.NewRandom()
+func hashOTPSecret(secret []byte) (hash string, receipt string) {
+ tokenID := sha1.Sum(secret[:])
+ receipt = base64.RawURLEncoding.EncodeToString(tokenID[:])
+ return hashOTPReceipt(receipt), receipt
+}
+func hashOTPReceipt(receipt string) (hash string) {
+ return base64.RawURLEncoding.EncodeToString([]byte(receipt))
+}
+
+func newOTP(email string, agent string, addr string) (id string, secret []byte, err error) {
+ uuid, err := uuid.NewRandom()
+ secret, _ = uuid.MarshalBinary()
if nil != err {
// nothing else to do if we run out of random
// or are on a platform that doesn't support random
panic(fmt.Errorf("random bytes read failure: %w", err))
}
- token := base64.RawURLEncoding.EncodeToString(rnd[:])
-
- // We hash the random value to prevent DB / FS / compare timing attacks
- tokenID := sha1.Sum([]byte(token))
- tokenName := base64.RawURLEncoding.EncodeToString(tokenID[:])
+ // The (double) hash becomes the file or DB id.
+ // Using this rather than the secret itself prevents DB / FS / compare timing attacks
+ hash, receipt := hashOTPSecret(secret)
+ otp := OTP{
+ CreatedAt: time.Now(),
+ Email: strings.ToLower(email),
+ ReceiptUA: agent,
+ ReceiptIP: addr,
+ //Attempts: 0,
+ }
+ otpJSON, _ := json.Marshal(otp)
if err := ioutil.WriteFile(
- filepath.Join(tokenPrefix, tokenName+".tok.txt"),
- []byte(`{"comment":"TODO: metadata goes here"}`),
+ filepath.Join(tokenPrefix, hash+".tok.txt"),
+ otpJSON,
// keep it secret, keep it safe
os.FileMode(0600),
); nil != err {
- return "", errors.New("database connection failed when writing verification token")
+ return "", nil, errors.New("database connection failed when writing verification token")
}
- return token, nil
+ return receipt, secret, nil
+}
+
+type otpConsumer struct {
+ secret bool
+ receipt bool
+}
+
+func consumeOTPSecret(email string, secret []byte, agent string, addr string) (*OTP, error) {
+ hash, _ := hashOTPSecret(secret)
+ return checkOTP(hash, email, agent, addr, otpConsumer{secret: true})
}
-func checkOTP(token string) error {
- // We hash the random value to prevent DB / FS / compare timing attacks
- tokenID := sha1.Sum([]byte(token))
- tokenName := base64.RawURLEncoding.EncodeToString(tokenID[:])
- tokfile := filepath.Join(tokenPrefix, tokenName+".tok.txt")
- if _, err := ioutil.ReadFile(tokfile); nil != err {
- return errors.New("database connection failed when reading verification token")
+func consumeOTPReceipt(email string, receipt string, agent string, addr string) (*OTP, error) {
+ hash := hashOTPReceipt(receipt)
+ return checkOTP(hash, email, agent, addr, otpConsumer{receipt: true})
+}
+
+func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error) {
+ email = strings.ToLower(email)
+ // the double hash will not leak timing info on lookup
+ tokfile := filepath.Join(tokenPrefix, hash+".tok.txt")
+ b, err := ioutil.ReadFile(tokfile)
+ if nil != err {
+ return nil, errors.New("database connection failed when reading verification token")
+ }
+
+ otp := OTP{}
+ if err := json.Unmarshal(b, &otp); nil != err {
+ return nil, errors.New("database verification token parse failed")
+ }
+
+ if 0 == subtle.ConstantTimeCompare([]byte(otp.Email), []byte(email)) {
+ // TODO error
+ // TODO increment attempts?
}
- // TODO promote JWK to public... and related to an ID or email??
- _ = os.Remove(tokfile)
- return nil
+ if consume.secret {
+ if !otp.SecretUsed.IsZero() {
+ return nil, errors.New("token has already been used")
+ }
+ otp.SecretUsed = time.Now()
+ if addr != otp.ReceiptIP {
+ otp.SecretUA = agent
+ otp.SecretIP = addr
+ }
+ if consume.receipt && otp.ReceiptUsed.IsZero() {
+ otp.ReceiptUsed = otp.SecretUsed
+ }
+ } else if consume.receipt {
+ if otp.SecretUsed.IsZero() {
+ return nil, errors.New("token has not been verified")
+ }
+ if !otp.ReceiptUsed.IsZero() {
+ return nil, errors.New("token has already been used")
+ }
+ otp.ReceiptUsed = time.Now()
+ }
+
+ if consume.secret || consume.receipt {
+ otpJSON, _ := json.Marshal(otp)
+ if err := ioutil.WriteFile(
+ filepath.Join(tokenPrefix, hash+".tok.txt"),
+ otpJSON,
+ // keep it secret, keep it safe
+ os.FileMode(0600),
+ ); nil != err {
+ return nil, errors.New("database connection failed when consuming token")
+ }
+ }
+
+ fmt.Println("SNTHSNTHSNTHSNTHSNTHSNTHSNTHSNTHNSTHSNTH GOOD!!")
+
+ return &otp, nil
}
-func startVerification(baseURL, contact string) error {
- email := strings.Replace(contact, "mailto:", "", -1)
+func startVerification(baseURL, contact, agent, addr string) (receipt string, err error) {
+ email := strings.Replace(strings.TrimPrefix(contact, "mailto:"), " ", "+", -1)
if "" == email {
- return errors.New("missing contact:[\"mailto:me@example.com\"]")
+ return "", errors.New("missing contact:[\"mailto:me@example.com\"]")
}
// TODO check DNS for MX records
if !strings.Contains(email, "@") || strings.Contains(email, " \t\n") {
- return errors.New("invalid email address")
+ return "", errors.New("invalid email address")
}
// TODO expect JWK in JWS/JWT
// TODO place validated JWK into file with token
- token, err := newOTP()
+ ua := ua.Parse(agent)
+
+ receipt, secret, err := newOTP(email, agent, addr)
if nil != err {
- return err
+ return "", err
}
subject := "Verify New Account"
// TODO go tpl
// TODO determine OS and Browser from user agent
text := fmt.Sprintf(
- "It looks like you just tried to register a new Pocket ID account.\n\n Verify account: %s/verify/%s\n\nNot you? Just ignore this message.",
- baseURL, token,
+ "It looks like you just tried to register a new Pocket ID account.\n\n Verify account: %s#/verify/%s\n\n%s on %s %s from %s\n\nNot you? Just ignore this message.",
+ baseURL, base64.RawURLEncoding.EncodeToString(secret), ua.Name, ua.OS, ua.Device, addr,
)
- if _, err = SendSimpleMessage(email, defaultFrom, subject, text, defaultReplyTo); nil != err {
- return err
+ fmt.Println("email:", text)
+ if !strings.Contains(contact, "+noreply") {
+ if _, err = SendSimpleMessage(email, defaultFrom, subject, text, defaultReplyTo); nil != err {
+ return "", err
+ }
}
- return nil
+ return receipt, nil
}
func getBaseURL(r *http.Request) string {
diff --git a/public/.gitignore b/public/.gitignore
new file mode 100644
index 0000000..d8b83df
--- /dev/null
+++ b/public/.gitignore
@@ -0,0 +1 @@
+package-lock.json
diff --git a/public/.jshintrc b/public/.jshintrc
new file mode 100644
index 0000000..4412674
--- /dev/null
+++ b/public/.jshintrc
@@ -0,0 +1,16 @@
+{ "node": true
+, "browser": true
+, "globals": { "Promise": true }
+, "esversion": 8
+
+, "indent": 2
+, "onevar": true
+, "laxbreak": true
+, "curly": true
+, "nonbsp": true
+
+, "eqeqeq": true
+, "immed": true
+, "undef": true
+, "unused": true
+}
diff --git a/public/.prettierignore b/public/.prettierignore
new file mode 100644
index 0000000..849ddff
--- /dev/null
+++ b/public/.prettierignore
@@ -0,0 +1 @@
+dist/
diff --git a/public/.prettierrc b/public/.prettierrc
new file mode 100644
index 0000000..7e5d770
--- /dev/null
+++ b/public/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "bracketSpacing": true,
+ "printWidth": 80,
+ "singleQuote": true,
+ "tabWidth": 4,
+ "trailingComma": "none",
+ "useTabs": true
+}
diff --git a/public/hashcash.js b/public/hashcash.js
new file mode 100644
index 0000000..751cd48
--- /dev/null
+++ b/public/hashcash.js
@@ -0,0 +1,79 @@
+'use strict';
+/*global crypto*/
+
+var Hashcash = module.exports;
+
+var textEncoder = new TextEncoder();
+Hashcash.solve = async function solveHc(hc) {
+ var solution = 0;
+ var parts = hc.split(':').slice(0, 6);
+ if (parts.length < 6) {
+ throw new Error('invalid Hashcash-Challenge: ' + hc);
+ }
+
+ var bits = parseInt(parts[1], 10) || -1;
+ if (bits > 10 || bits < 0) {
+ throw new Error('bad bit values');
+ }
+ console.log('bits:', bits);
+ hc = parts.join(':') + ':';
+ async function next() {
+ var answer = hc + int52ToBase64(solution);
+ var u8 = textEncoder.encode(answer);
+ // REALLY SLOW due to async tasks and C++ context switch
+ var hash = await crypto.subtle.digest('SHA-256', u8);
+ hash = new Uint8Array(hash);
+ if (checkHc(hash, bits)) {
+ return answer;
+ }
+ solution += 1;
+ return next();
+ }
+
+ return next();
+};
+
+function int52ToBase64(n) {
+ var hex = n.toString(16);
+ if (hex.length % 2) {
+ hex = '0' + hex;
+ }
+
+ var bin = [];
+ var i = 0;
+ var d;
+ var b;
+ while (i < hex.length) {
+ d = parseInt(hex.slice(i, i + 2), 16);
+ b = String.fromCharCode(d);
+ bin.push(b);
+ i += 2;
+ }
+
+ return btoa(bin.join('')).replace(/=/g, '');
+}
+
+function checkHc(hash, bits) {
+ var n = Math.floor(bits / 8);
+ var m = bits % 8;
+ var i;
+ if (m > 0) {
+ n += 1;
+ }
+
+ for (i = 0; i < n && i < hash.length; i += 1) {
+ if (bits > 8) {
+ bits -= 8;
+ if (0 !== hash[i]) {
+ return false;
+ }
+ continue;
+ }
+
+ if (0 !== hash[i] >> (8 - bits)) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/public/index.html b/public/index.html
index f2403d7..dfc3dc8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,31 +1,45 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Tokens for Testing
Compatible with
@@ -140,26 +154,110 @@ You shouldn't use it for automated testing, because it will change, but it looks
}
-
-
-
-
-
-
-
+
+
+
+
+
⌛
+
+
+
+
+
+
+ Choose Password
+
+
+
+
+
+
+
+ Existing User
+
+
+
+
+
+
+ Incorrect Password
+
+
+
+
+
+
+ New Device
+ Check your email to confirm new device.
+
+
+
+
+
+
+
diff --git a/public/main.js b/public/main.js
new file mode 100644
index 0000000..59f2caf
--- /dev/null
+++ b/public/main.js
@@ -0,0 +1,199 @@
+'use strict';
+
+var request = require('./request.js');
+var PocketId = require('./pocketid.js');
+var state = {};
+var auths = clearAuths();
+
+function $$(sel, el) {
+ if (el) {
+ return el.querySelectorAll(sel) || [];
+ }
+ return document.body.querySelectorAll(sel) || [];
+}
+
+function $(sel, el) {
+ if (el) {
+ return el.querySelector(sel);
+ }
+ return document.body.querySelector(sel);
+}
+
+function clearAuths() {
+ var _auths = {
+ google: {
+ promise: null,
+ idToken: ''
+ }
+ };
+ _auths.google.promise = new Promise(function (res, rej) {
+ _auths.google.resolve = res;
+ _auths.google.reject = rej;
+ });
+ return _auths;
+}
+
+window.onSignIn = async function onSignIn(googleUser) {
+ // Useful data for your client-side scripts:
+ var profile = googleUser.getBasicProfile();
+ // Don't send this directly to your server!
+ console.log('ID: ' + profile.getId());
+ console.log('Full Name: ' + profile.getName());
+ console.log('Given Name: ' + profile.getGivenName());
+ console.log('Family Name: ' + profile.getFamilyName());
+ console.log('Image URL: ' + profile.getImageUrl());
+ console.log('Email: ' + profile.getEmail());
+
+ // The ID token you need to pass to your backend:
+ auths.google.idToken = googleUser.getAuthResponse().id_token;
+ console.log('ID Token: ' + auths.google.idToken);
+ auths.google.resolve(auths.google.idToken);
+};
+
+function setFlow(cont, flow) {
+ $$(cont).forEach(function (el) {
+ el.hidden = true;
+ });
+ console.log(flow);
+ $(flow).hidden = false;
+}
+
+async function unlock() {
+ var key;
+ try {
+ key = await PocketId.unlock(function () {
+ setFlow('.authn-container', '.authn-unlock');
+ return new Promise(function (resolve, reject) {
+ window.unlocker = { resolve: resolve, reject: reject };
+ });
+ });
+ } catch (e) {
+ console.error(
+ "Had a key, but couldn't unlock it. TODO: Just send email?"
+ );
+ console.error(e);
+ return;
+ }
+
+ setFlow('.authn-container', '.authn-loading');
+
+ if (key) {
+ genTokenWithKey(key);
+ return;
+ await PocketId.createIdToken({ key: key });
+ }
+
+ PocketId.signIdToken(id_token).then(function (resp) {
+ console.log('Response:');
+ console.log(resp);
+ });
+}
+
+function genTokenWithKey() {
+ // TODO: generate token
+ // TODO: check if the key is still considered valid
+ // TODO: generate new key and authorize
+}
+
+(async function () {
+ var loc = window.location;
+
+ console.log('/new-hashcash?');
+ var resp = await request({
+ method: 'POST',
+ url: loc.protocol + '//' + loc.hostname + '/api/new-hashcash'
+ });
+ console.log(resp);
+
+ console.log('/test-hashcash?');
+ resp = await request({
+ method: 'POST',
+ url: loc.protocol + '//' + loc.hostname + '/api/test-hashcash'
+ });
+ console.log(resp);
+})();
+
+setFlow('.authn-container', '.authn-email');
+
+$('.authn-email form').addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ state.email = $('.authn-email [name=username]').value;
+
+ setFlow('.authn-container', '.authn-loading');
+ return PocketId.auth
+ .meta({ email: state.email })
+ .catch(function (err) {
+ window.alert('Error: ' + err.message);
+ })
+ .then(function (resp) {
+ // if the user exists, go to the continue screen
+ // otherwise go to the new user screen
+ console.log('meta:', resp);
+ if (!resp.body.success) {
+ // This is a completely new user
+ setFlow('.authn-container', '.authn-new-user');
+ return;
+ }
+ // The user exists, but this is a new device
+ setFlow('.authn-container', '.authn-existing');
+ });
+});
+
+$('.authn-new-user form').addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ // We don't need to worry about checking if the key exists
+ // even if it does, the account has been deactivated
+
+ setFlow('.authn-container', '.authn-loading');
+ return PocketId.auth
+ .verify({ scheme: 'mailto:', email: state.email })
+ .catch(function (err) {
+ window.alert('Error: ' + err.message);
+ })
+ .then(function (resp) {
+ console.log(resp);
+ localStorage.setItem(
+ 'pocketid', // + state.email,
+ JSON.stringify({
+ receipt: resp.body.receipt,
+ email: state.email,
+ createdAt: new Date().toISOString()
+ })
+ );
+ window.alert("Go check yo' email!");
+ return PocketId.auth
+ .consume({
+ email: state.email,
+ receipt: resp.body.receipt
+ })
+ .then(function (resp) {
+ window.alert('all set!');
+ });
+ });
+});
+
+var route = window.location.hash.split('/').slice(1);
+console.log('route:', route);
+switch (route[0]) {
+ case 'verify':
+ var pstate = JSON.parse(localStorage.getItem('pocketid') || '{}');
+ PocketId.auth
+ .consume({
+ receipt: pstate.receipt,
+ secret: route[1]
+ })
+ .then(function (resp) {
+ console.log('token for this device to save:', resp);
+ window.alert('goodness!');
+ })
+ .catch(function (e) {
+ console.error(e);
+ window.alert('network error, try again');
+ });
+ break;
+ default:
+ // do nothing
+}
diff --git a/public/package.json b/public/package.json
new file mode 100644
index 0000000..04fdece
--- /dev/null
+++ b/public/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "pocketid",
+ "version": "0.1.0",
+ "description": "ID tokens made easy",
+ "main": "pocketid.js",
+ "scripts": {
+ "prettier": "prettier --write '**/*.{css,js,md}'",
+ "test": "node pocketid_test.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://example.com/pocketid.git"
+ },
+ "keywords": [
+ "oauth1",
+ "oauth2",
+ "oauth3",
+ "oidc",
+ "acme",
+ "jwt",
+ "jose",
+ "jws",
+ "jwk"
+ ],
+ "author": "AJ ONeal ",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@root/keypairs": "^0.10.1"
+ },
+ "devDependencies": {
+ "webpack": "^5.0.0-beta.28",
+ "webpack-cli": "^3.3.12"
+ }
+}
diff --git a/public/pocketid.js b/public/pocketid.js
new file mode 100644
index 0000000..7b54dde
--- /dev/null
+++ b/public/pocketid.js
@@ -0,0 +1,118 @@
+'use strict';
+
+var Keypairs = require('@root/keypairs');
+var PocketId = module.exports;
+var request = require('./request.js');
+
+var keyJson = window.localStorage.getItem('private.jwk.json');
+
+PocketId.signIdToken = async function (idToken) {
+ var pair = await Keypairs.parseOrGenerate({ key: keyJson });
+ var jwt = await Keypairs.signJwt({
+ jwk: pair.private,
+ iss: window.location.protocol + '//' + window.location.hostname,
+ exp: '15m',
+ claims: {
+ contact: ['google:' + idToken]
+ }
+ });
+ return jwt;
+};
+
+PocketId.auth = {};
+PocketId.auth.meta = async function ({ email }) {
+ var loc = window.location;
+ var body = await request({
+ method: 'GET',
+ url:
+ loc.protocol +
+ '//' +
+ loc.hostname +
+ '/api/authn/meta?contact=' +
+ 'mailto:' +
+ email
+ });
+ return body;
+};
+
+PocketId.auth.verify = async function ({ scheme, email }) {
+ if (!scheme) {
+ scheme = 'mailto:';
+ }
+
+ var loc = window.location;
+ var body = await request({
+ method: 'GET',
+ url:
+ loc.protocol +
+ '//' +
+ loc.hostname +
+ '/api/authn/verify?contact=' +
+ scheme +
+ email
+ });
+
+ return body;
+};
+
+PocketId.auth.consume = async function ({
+ email = '',
+ receipt = '',
+ secret = '',
+ count = 0
+}) {
+ var loc = window.location;
+ var resp = await request({
+ method: 'GET',
+ url:
+ loc.protocol +
+ '//' +
+ loc.hostname +
+ '/api/authn/consume?contact=' +
+ (email ? 'mailto:' + email : '') +
+ '&receipt=' +
+ receipt +
+ '&secret=' +
+ secret
+ });
+
+ if (resp.body.success) {
+ // There should be a token here
+ // (or the pubkey should have been given beforehand)
+ return resp.body;
+ }
+
+ if (resp.body.error) {
+ // TODO special errors are hard failures
+ }
+
+ if (count > 600) {
+ throw new Error('abandoned login');
+ }
+
+ return timeout(5000).then(function () {
+ console.log('check otp again');
+ return PocketId.auth.consume({
+ email,
+ secret,
+ receipt,
+ count: count || 0
+ });
+ });
+};
+
+async function timeout(ms) {
+ return new Promise(function (resolve) {
+ setTimeout(resolve, ms);
+ });
+}
+
+var textEncoder = new TextEncoder();
+PocketId.genKey = async function ({ email }) {
+ // Ideally we'd use PBKDF2 or better but... web standards...
+ // TODO put a random salt
+ var emailU8 = textEncoder.encode(email);
+ var salt = await crypto.subtle.digest('SHA-256', emailU8);
+ var u8 = textEncoder.encode(answer);
+ var hash = await crypto.subtle.digest('SHA-256', u8);
+};
diff --git a/public/request.js b/public/request.js
new file mode 100644
index 0000000..c9a23d5
--- /dev/null
+++ b/public/request.js
@@ -0,0 +1,85 @@
+'use strict';
+
+var Hashcash = require('./hashcash.js');
+var _sites = {};
+
+module.exports = async function (opts) {
+ if (!opts.headers) {
+ opts.headers = {};
+ }
+
+ if (opts.json) {
+ if (true === opts.json) {
+ opts.body = JSON.stringify(opts.body);
+ } else {
+ opts.body = JSON.stringify(opts.json);
+ }
+ if (!opts.headers['Content-Type'] && !opts.headers['content-type']) {
+ opts.headers['Content-Type'] = 'application/json';
+ }
+ }
+
+ if (!opts.mode) {
+ opts.mode = 'cors';
+ }
+
+ var url = new URL(opts.url);
+ if (!_sites[url.hostname]) {
+ _sites[url.hostname] = { nonces: [] };
+ }
+
+ var site = _sites[url.hostname];
+ var hc = site.hashcashChallenge;
+ if (hc) {
+ delete site.hashcashChallenge;
+ site.hashcash = await Hashcash.solve(hc);
+ }
+ if (site.hashcash) {
+ opts.headers.Hashcash = site.hashcash;
+ }
+
+ var response = await window.fetch(opts.url, opts);
+ var headerNames = response.headers.keys();
+ var hs = {};
+ var h;
+ while (true) {
+ h = headerNames.next();
+ if (h.done) {
+ break;
+ }
+ hs[h.value] = response.headers.get(h.value);
+ }
+
+ var body;
+ if (hs['content-type'].includes('application/json')) {
+ body = await response.json();
+ } else {
+ body = await response.text();
+ try {
+ body = JSON.parse(body);
+ } catch (e) {
+ // ignore
+ }
+ }
+ var resp = {};
+
+ resp.body = body;
+ resp.headers = hs;
+ resp.toJSON = function () {
+ return {
+ headers: hs,
+ body: body
+ };
+ };
+
+ if (resp.headers['hashcash-challenge']) {
+ _sites[url.hostname].hashcashChallenge =
+ resp.headers['hashcash-challenge'];
+ }
+ if (resp.headers.nonce) {
+ site.nonces.push(resp.headers.nonce);
+ }
+
+ console.log(resp);
+ return resp;
+};
diff --git a/public/webpack.config.js b/public/webpack.config.js
new file mode 100644
index 0000000..87f2da9
--- /dev/null
+++ b/public/webpack.config.js
@@ -0,0 +1,19 @@
+'use strict';
+
+var path = require('path');
+
+module.exports = {
+ entry: './main.js',
+ mode: 'development',
+ devServer: {
+ contentBase: path.join(__dirname, 'dist'),
+ port: 3001
+ },
+ output: {
+ publicPath: 'http://localhost:3001/'
+ },
+ module: {
+ rules: [{}]
+ },
+ plugins: []
+};
diff --git a/vendor/github.com/mileusna/useragent/.gitignore b/vendor/github.com/mileusna/useragent/.gitignore
new file mode 100644
index 0000000..b9064a7
--- /dev/null
+++ b/vendor/github.com/mileusna/useragent/.gitignore
@@ -0,0 +1,4 @@
+old.go
+old_test.go
+file.txt
+user_agents.txt
diff --git a/vendor/github.com/mileusna/useragent/LICENSE b/vendor/github.com/mileusna/useragent/LICENSE
new file mode 100644
index 0000000..2da0046
--- /dev/null
+++ b/vendor/github.com/mileusna/useragent/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Miloš Mileusnić
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/vendor/github.com/mileusna/useragent/README.md b/vendor/github.com/mileusna/useragent/README.md
new file mode 100644
index 0000000..1a51392
--- /dev/null
+++ b/vendor/github.com/mileusna/useragent/README.md
@@ -0,0 +1,97 @@
+# Go/Golang package for parsing user agent strings [![GoDoc](https://godoc.org/github.com/mileusna/useragent?status.svg)](https://godoc.org/github.com/mileusna/useragent)
+
+Package `ua.Parse(userAgent string)` function parses browser's and bot's user agents strings and determins:
++ User agent name and version (Chrome, Firefox, Googlebot, etc.)
++ Operating system name and version (Windows, Android, iOS etc.)
++ Device type (mobile, desktop, tablet, bot)
++ Device name if available (iPhone, iPad, Huawei VNS-L21)
++ URL provided by the bot (http://www.google.com/bot.html etc.)
+
+## Status
+
+Still need some work on detecting Andorid device names.
+
+## Installation
+```
+go get github.com/mileusna/useragent
+```
+
+## Example
+
+```go
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/mileusna/useragent"
+)
+
+func main() {
+ userAgents := []string{
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8",
+ "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.0 Mobile/14F89 Safari/602.1",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) FxiOS/8.1.1b4948 Mobile/14F89 Safari/603.2.4",
+ "Mozilla/5.0 (iPad; CPU OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.0 Mobile/14F89 Safari/602.1",
+ "Mozilla/5.0 (Linux; Android 4.3; GT-I9300 Build/JSS15J) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.125 Mobile Safari/537.36",
+ "Mozilla/5.0 (Android 4.3; Mobile; rv:54.0) Gecko/54.0 Firefox/54.0",
+ "Mozilla/5.0 (Linux; Android 4.3; GT-I9300 Build/JSS15J) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36 OPR/42.9.2246.119956",
+ "Opera/9.80 (Android; Opera Mini/28.0.2254/66.318; U; en) Presto/2.12.423 Version/12.16",
+ }
+
+ for _, s := range userAgents {
+ ua := ua.Parse(s)
+ fmt.Println()
+ fmt.Println(ua.String)
+ fmt.Println(strings.Repeat("=", len(ua.String)))
+ fmt.Println("Name:", ua.Name, "v", ua.Version)
+ fmt.Println("OS:", ua.OS, "v", ua.OSVersion)
+ fmt.Println("Device:", ua.Device)
+ if ua.Mobile {
+ fmt.Println("(Mobile)")
+ }
+ if ua.Tablet {
+ fmt.Println("(Tablet)")
+ }
+ if ua.Desktop {
+ fmt.Println("(Desktop)")
+ }
+ if ua.Bot {
+ fmt.Println("(Bot)")
+ }
+ if ua.URL != "" {
+ fmt.Println(ua.URL)
+ }
+ }
+}
+
+
+```
+
+## Shorthand functions
+
+Beside `UserAgent{}` struct and its properties returned by `ua.Parse()`, there is a bunch of shorthand functions for most popular browsers and operating systems, so this code:
+
+```go
+ ua := ua.Parse(userAgentString)
+ if ua.OS == "Android" && ua.Name == "Chrome" {
+ // do something
+ }
+```
+can be also written on this way:
+```go
+ ua := ua.Parse(userAgentString)
+ if ua.IsAndroid() && ua.IsChrome() {
+ // do something
+ }
+```
+
+## Notice
+
++ Opera and Opera Mini are two browsers, since they operate on very different ways.
++ If Googlebot (or any other bot) is detected and it is using its mobile crawler, both `bot` and `mobile` flags will be set to `true`.
+
+
+
diff --git a/vendor/github.com/mileusna/useragent/go.mod b/vendor/github.com/mileusna/useragent/go.mod
new file mode 100644
index 0000000..ab4182e
--- /dev/null
+++ b/vendor/github.com/mileusna/useragent/go.mod
@@ -0,0 +1,3 @@
+module github.com/mileusna/useragent
+
+go 1.14
diff --git a/vendor/github.com/mileusna/useragent/is.go b/vendor/github.com/mileusna/useragent/is.go
new file mode 100644
index 0000000..d312b24
--- /dev/null
+++ b/vendor/github.com/mileusna/useragent/is.go
@@ -0,0 +1,76 @@
+package ua
+
+// IsWindows shorthand function to check if OS == Windows
+func (ua UserAgent) IsWindows() bool {
+ return ua.OS == Windows
+}
+
+// IsAndroid shorthand function to check if OS == Android
+func (ua UserAgent) IsAndroid() bool {
+ return ua.OS == Android
+}
+
+// IsMacOS shorthand function to check if OS == MacOS
+func (ua UserAgent) IsMacOS() bool {
+ return ua.OS == MacOS
+}
+
+// IsIOS shorthand function to check if OS == IOS
+func (ua UserAgent) IsIOS() bool {
+ return ua.OS == IOS
+}
+
+// IsLinux shorthand function to check if OS == Linux
+func (ua UserAgent) IsLinux() bool {
+ return ua.OS == Linux
+}
+
+// IsOpera shorthand function to check if Name == Opera
+func (ua UserAgent) IsOpera() bool {
+ return ua.Name == Opera
+}
+
+// IsOperaMini shorthand function to check if Name == Opera Mini
+func (ua UserAgent) IsOperaMini() bool {
+ return ua.Name == OperaMini
+}
+
+// IsChrome shorthand function to check if Name == Chrome
+func (ua UserAgent) IsChrome() bool {
+ return ua.Name == Chrome
+}
+
+// IsFirefox shorthand function to check if Name == Firefox
+func (ua UserAgent) IsFirefox() bool {
+ return ua.Name == Firefox
+}
+
+// IsInternetExplorer shorthand function to check if Name == Internet Explorer
+func (ua UserAgent) IsInternetExplorer() bool {
+ return ua.Name == InternetExplorer
+}
+
+// IsSafari shorthand function to check if Name == Safari
+func (ua UserAgent) IsSafari() bool {
+ return ua.Name == Safari
+}
+
+// IsEdge shorthand function to check if Name == Edge
+func (ua UserAgent) IsEdge() bool {
+ return ua.Name == Edge
+}
+
+// IsGooglebot shorthand function to check if Name == Googlebot
+func (ua UserAgent) IsGooglebot() bool {
+ return ua.Name == Googlebot
+}
+
+// IsTwitterbot shorthand function to check if Name == Twitterbot
+func (ua UserAgent) IsTwitterbot() bool {
+ return ua.Name == Twitterbot
+}
+
+// IsFacebookbot shorthand function to check if Name == FacebookExternalHit
+func (ua UserAgent) IsFacebookbot() bool {
+ return ua.Name == FacebookExternalHit
+}
diff --git a/vendor/github.com/mileusna/useragent/ua.go b/vendor/github.com/mileusna/useragent/ua.go
new file mode 100644
index 0000000..b4deb4d
--- /dev/null
+++ b/vendor/github.com/mileusna/useragent/ua.go
@@ -0,0 +1,434 @@
+package ua
+
+import (
+ "bytes"
+ "regexp"
+ "strings"
+)
+
+// UserAgent struct containg all determined datra from parsed user-agent string
+type UserAgent struct {
+ Name string
+ Version string
+ OS string
+ OSVersion string
+ Device string
+ Mobile bool
+ Tablet bool
+ Desktop bool
+ Bot bool
+ URL string
+ String string
+}
+
+var ignore = map[string]struct{}{
+ "KHTML, like Gecko": struct{}{},
+ "U": struct{}{},
+ "compatible": struct{}{},
+ "Mozilla": struct{}{},
+ "WOW64": struct{}{},
+}
+
+// Constants for browsers and operating systems for easier comparation
+const (
+ Windows = "Windows"
+ WindowsPhone = "Windows Phone"
+ Android = "Android"
+ MacOS = "macOS"
+ IOS = "iOS"
+ Linux = "Linux"
+
+ Opera = "Opera"
+ OperaMini = "Opera Mini"
+ OperaTouch = "Opera Touch"
+ Chrome = "Chrome"
+ Firefox = "Firefox"
+ InternetExplorer = "Internet Explorer"
+ Safari = "Safari"
+ Edge = "Edge"
+ Vivaldi = "Vivaldi"
+
+ Googlebot = "Googlebot"
+ Twitterbot = "Twitterbot"
+ FacebookExternalHit = "facebookexternalhit"
+ Applebot = "Applebot"
+)
+
+// Parse user agent string returning UserAgent struct
+func Parse(userAgent string) UserAgent {
+ ua := UserAgent{
+ String: userAgent,
+ }
+
+ tokens := parse(userAgent)
+
+ // check is there URL
+ for k := range tokens {
+ if strings.HasPrefix(k, "http://") || strings.HasPrefix(k, "https://") {
+ ua.URL = k
+ delete(tokens, k)
+ break
+ }
+ }
+
+ // OS lookup
+ switch {
+ case tokens.exists("Android"):
+ ua.OS = Android
+ ua.OSVersion = tokens[Android]
+ for s := range tokens {
+ if strings.HasSuffix(s, "Build") {
+ ua.Device = strings.TrimSpace(s[:len(s)-5])
+ ua.Tablet = strings.Contains(strings.ToLower(ua.Device), "tablet")
+ }
+ }
+
+ case tokens.exists("iPhone"):
+ ua.OS = IOS
+ ua.OSVersion = tokens.findMacOSVersion()
+ ua.Device = "iPhone"
+ ua.Mobile = true
+
+ case tokens.exists("iPad"):
+ ua.OS = IOS
+ ua.OSVersion = tokens.findMacOSVersion()
+ ua.Device = "iPad"
+ ua.Tablet = true
+
+ case tokens.exists("Windows NT"):
+ ua.OS = Windows
+ ua.OSVersion = tokens["Windows NT"]
+ ua.Desktop = true
+
+ case tokens.exists("Windows Phone OS"):
+ ua.OS = WindowsPhone
+ ua.OSVersion = tokens["Windows Phone OS"]
+ ua.Mobile = true
+
+ case tokens.exists("Macintosh"):
+ ua.OS = MacOS
+ ua.OSVersion = tokens.findMacOSVersion()
+ ua.Desktop = true
+
+ case tokens.exists("Linux"):
+ ua.OS = Linux
+ ua.OSVersion = tokens[Linux]
+ ua.Desktop = true
+
+ }
+
+ // for s, val := range sys {
+ // fmt.Println(s, "--", val)
+ // }
+
+ switch {
+
+ case tokens.exists("Googlebot"):
+ ua.Name = Googlebot
+ ua.Version = tokens[Googlebot]
+ ua.Bot = true
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens.exists("Applebot"):
+ ua.Name = Applebot
+ ua.Version = tokens[Applebot]
+ ua.Bot = true
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+ ua.OS = ""
+
+ case tokens["Opera Mini"] != "":
+ ua.Name = OperaMini
+ ua.Version = tokens[OperaMini]
+ ua.Mobile = true
+
+ case tokens["OPR"] != "":
+ ua.Name = Opera
+ ua.Version = tokens["OPR"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["OPT"] != "":
+ ua.Name = OperaTouch
+ ua.Version = tokens["OPT"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ // Opera on iOS
+ case tokens["OPiOS"] != "":
+ ua.Name = Opera
+ ua.Version = tokens["OPiOS"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ // Chrome on iOS
+ case tokens["CriOS"] != "":
+ ua.Name = Chrome
+ ua.Version = tokens["CriOS"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ // Firefox on iOS
+ case tokens["FxiOS"] != "":
+ ua.Name = Firefox
+ ua.Version = tokens["FxiOS"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["Firefox"] != "":
+ ua.Name = Firefox
+ ua.Version = tokens[Firefox]
+ _, ua.Mobile = tokens["Mobile"]
+ _, ua.Tablet = tokens["Tablet"]
+
+ case tokens["Vivaldi"] != "":
+ ua.Name = Vivaldi
+ ua.Version = tokens[Vivaldi]
+
+ case tokens.exists("MSIE"):
+ ua.Name = InternetExplorer
+ ua.Version = tokens["MSIE"]
+
+ case tokens["EdgiOS"] != "":
+ ua.Name = Edge
+ ua.Version = tokens["EdgiOS"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["Edge"] != "":
+ ua.Name = Edge
+ ua.Version = tokens["Edge"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["Edg"] != "":
+ ua.Name = Edge
+ ua.Version = tokens["Edg"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["EdgA"] != "":
+ ua.Name = Edge
+ ua.Version = tokens["EdgA"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["bingbot"] != "":
+ ua.Name = "Bingbot"
+ ua.Version = tokens["bingbot"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens["SamsungBrowser"] != "":
+ ua.Name = "Samsung Browser"
+ ua.Version = tokens["SamsungBrowser"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ // if chrome and Safari defined, find any other tokensent descr
+ case tokens.exists(Chrome) && tokens.exists(Safari):
+ name := tokens.findBestMatch(true)
+ if name != "" {
+ ua.Name = name
+ ua.Version = tokens[name]
+ break
+ }
+ fallthrough
+
+ case tokens.exists("Chrome"):
+ ua.Name = Chrome
+ ua.Version = tokens["Chrome"]
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ case tokens.exists("Safari"):
+ ua.Name = Safari
+ if v, ok := tokens["Version"]; ok {
+ ua.Version = v
+ } else {
+ ua.Version = tokens["Safari"]
+ }
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+
+ default:
+ if ua.OS == "Android" && tokens["Version"] != "" {
+ ua.Name = "Android browser"
+ ua.Version = tokens["Version"]
+ ua.Mobile = true
+ } else {
+ if name := tokens.findBestMatch(false); name != "" {
+ ua.Name = name
+ ua.Version = tokens[name]
+ } else {
+ ua.Name = ua.String
+ }
+ ua.Bot = strings.Contains(strings.ToLower(ua.Name), "bot")
+ ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari")
+ }
+ }
+
+ // if tabler, switch mobile to off
+ if ua.Tablet {
+ ua.Mobile = false
+ }
+
+ // if not already bot, check some popular bots and weather URL is set
+ if !ua.Bot {
+ ua.Bot = ua.URL != ""
+ }
+
+ if !ua.Bot {
+ switch ua.Name {
+ case Twitterbot, FacebookExternalHit:
+ ua.Bot = true
+ }
+ }
+
+ return ua
+}
+
+func parse(userAgent string) (clients properties) {
+ clients = make(map[string]string, 0)
+ slash := false
+ isURL := false
+ var buff, val bytes.Buffer
+ addToken := func() {
+ if buff.Len() != 0 {
+ s := strings.TrimSpace(buff.String())
+ if _, ign := ignore[s]; !ign {
+ if isURL {
+ s = strings.TrimPrefix(s, "+")
+ }
+
+ if val.Len() == 0 { // only if value don't exists
+ var ver string
+ s, ver = checkVer(s) // determin version string and split
+ clients[s] = ver
+ } else {
+ clients[s] = strings.TrimSpace(val.String())
+ }
+ }
+ }
+ buff.Reset()
+ val.Reset()
+ slash = false
+ isURL = false
+ }
+
+ parOpen := false
+
+ bua := []byte(userAgent)
+ for i, c := range bua {
+
+ //fmt.Println(string(c), c)
+ switch {
+ case c == 41: // )
+ addToken()
+ parOpen = false
+
+ case parOpen && c == 59: // ;
+ addToken()
+
+ case c == 40: // (
+ addToken()
+ parOpen = true
+
+ case slash && c == 32:
+ addToken()
+
+ case slash:
+ val.WriteByte(c)
+
+ case c == 47 && !isURL: // /
+ if i != len(bua)-1 && bua[i+1] == 47 && (bytes.HasSuffix(buff.Bytes(), []byte("http:")) || bytes.HasSuffix(buff.Bytes(), []byte("https:"))) {
+ buff.WriteByte(c)
+ isURL = true
+ } else {
+ slash = true
+ }
+
+ default:
+ buff.WriteByte(c)
+ }
+ }
+ addToken()
+
+ return clients
+}
+
+func checkVer(s string) (name, v string) {
+ i := strings.LastIndex(s, " ")
+ if i == -1 {
+ return s, ""
+ }
+
+ //v = s[i+1:]
+
+ switch s[:i] {
+ case "Linux", "Windows NT", "Windows Phone OS", "MSIE", "Android":
+ return s[:i], s[i+1:]
+ default:
+ return s, ""
+ }
+
+ // for _, c := range v {
+ // if (c >= 48 && c <= 57) || c == 46 {
+ // } else {
+ // return s, ""
+ // }
+ // }
+
+ // return s[:i], s[i+1:]
+
+}
+
+type properties map[string]string
+
+func (p properties) exists(key string) bool {
+ _, ok := p[key]
+ return ok
+}
+
+func (p properties) existsAny(keys ...string) bool {
+ for _, k := range keys {
+ if _, ok := p[k]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func (p properties) findMacOSVersion() string {
+ for k, v := range p {
+ if strings.Contains(k, "OS") {
+ if ver := findVersion(v); ver != "" {
+ return ver
+ } else if ver = findVersion(k); ver != "" {
+ return ver
+ }
+ }
+ }
+ return ""
+}
+
+// findBestMatch from the rest of the bunch
+// in first cycle only return key vith version value
+// if withVerValue is false, do another cycle and return any token
+func (p properties) findBestMatch(withVerOnly bool) string {
+ n := 2
+ if withVerOnly {
+ n = 1
+ }
+ for i := 0; i < n; i++ {
+ for k, v := range p {
+ switch k {
+ case Chrome, Firefox, Safari, "Version", "Mobile", "Mobile Safari", "Mozilla", "AppleWebKit", "Windows NT", "Windows Phone OS", Android, "Macintosh", Linux, "GSA":
+ default:
+ if i == 0 {
+ if v != "" { // in first check, only return keys with value
+ return k
+ }
+ } else {
+ return k
+ }
+ }
+ }
+ }
+ return ""
+}
+
+var rxMacOSVer = regexp.MustCompile("[_\\d\\.]+")
+
+func findVersion(s string) string {
+ if ver := rxMacOSVer.FindString(s); ver != "" {
+ return strings.Replace(ver, "_", ".", -1)
+ }
+ return ""
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index e29e369..52bd083 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -19,5 +19,7 @@ github.com/mailru/easyjson
github.com/mailru/easyjson/buffer
github.com/mailru/easyjson/jlexer
github.com/mailru/easyjson/jwriter
+# github.com/mileusna/useragent v1.0.2
+github.com/mileusna/useragent
# github.com/pkg/errors v0.8.1
github.com/pkg/errors