From 6981b852d0cb995bc6a35a677b0a3151f1e7bcce Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 13 Sep 2020 05:55:12 +0000 Subject: [PATCH] first draft of login exchanges complete --- go.mod | 1 + go.sum | 2 + mockid/hashcash.go | 6 +- mockid/mockid.go | 4 +- mockid/route.go | 324 +++++++++++-- public/.gitignore | 1 + public/.jshintrc | 16 + public/.prettierignore | 1 + public/.prettierrc | 8 + public/hashcash.js | 79 ++++ public/index.html | 196 ++++++-- public/main.js | 199 ++++++++ public/package.json | 34 ++ public/pocketid.js | 118 +++++ public/request.js | 85 ++++ public/webpack.config.js | 19 + .../github.com/mileusna/useragent/.gitignore | 4 + vendor/github.com/mileusna/useragent/LICENSE | 21 + .../github.com/mileusna/useragent/README.md | 97 ++++ vendor/github.com/mileusna/useragent/go.mod | 3 + vendor/github.com/mileusna/useragent/is.go | 76 +++ vendor/github.com/mileusna/useragent/ua.go | 434 ++++++++++++++++++ vendor/modules.txt | 2 + 23 files changed, 1635 insertions(+), 95 deletions(-) create mode 100644 public/.gitignore create mode 100644 public/.jshintrc create mode 100644 public/.prettierignore create mode 100644 public/.prettierrc create mode 100644 public/hashcash.js create mode 100644 public/main.js create mode 100644 public/package.json create mode 100644 public/pocketid.js create mode 100644 public/request.js create mode 100644 public/webpack.config.js create mode 100644 vendor/github.com/mileusna/useragent/.gitignore create mode 100644 vendor/github.com/mileusna/useragent/LICENSE create mode 100644 vendor/github.com/mileusna/useragent/README.md create mode 100644 vendor/github.com/mileusna/useragent/go.mod create mode 100644 vendor/github.com/mileusna/useragent/is.go create mode 100644 vendor/github.com/mileusna/useragent/ua.go 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 }
-
- -
- - - - +
+ +
+
+
+
+ +
+
+
+

Login

+ +
+ +
+
+
+
+
+
+ +
+
+

Choose Password

+
+ +
+ +
+ Already have an account? + +
+
+
+ +
+ +
+

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