first draft of login exchanges complete
This commit is contained in:
parent
673671147c
commit
6981b852d0
1
go.mod
1
go.mod
|
@ -8,4 +8,5 @@ require (
|
||||||
github.com/google/uuid v1.1.1
|
github.com/google/uuid v1.1.1
|
||||||
github.com/joho/godotenv v1.3.0
|
github.com/joho/godotenv v1.3.0
|
||||||
github.com/mailgun/mailgun-go/v3 v3.6.4
|
github.com/mailgun/mailgun-go/v3 v3.6.4
|
||||||
|
github.com/mileusna/useragent v1.0.2
|
||||||
)
|
)
|
||||||
|
|
2
go.sum
2
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/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 h1:2gxZ0XQIU/5z3Z3bUBu+FXuk2pFbkN6tcwi/pjyaDic=
|
||||||
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
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 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
|
|
@ -28,7 +28,7 @@ func NewHashcash(sub string, exp time.Time) *hashcash.Hashcash {
|
||||||
|
|
||||||
var ErrNotFound = errors.New("not found")
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
func UseHashcash(hc string) error {
|
func UseHashcash(hc, sub string) error {
|
||||||
phony, err := hashcash.Parse(hc)
|
phony, err := hashcash.Parse(hc)
|
||||||
if nil != err {
|
if nil != err {
|
||||||
return err
|
return err
|
||||||
|
@ -42,7 +42,7 @@ func UseHashcash(hc string) error {
|
||||||
mccopy := *mccoy
|
mccopy := *mccoy
|
||||||
|
|
||||||
mccopy.Solution = phony.Solution
|
mccopy.Solution = phony.Solution
|
||||||
if err := mccopy.Verify("*"); nil != err {
|
if err := mccopy.Verify(sub); nil != err {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ func requireHashcash(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
hc := r.Header.Get("Hashcash")
|
hc := r.Header.Get("Hashcash")
|
||||||
_ = issueHashcash(w, r)
|
_ = issueHashcash(w, r)
|
||||||
if err := UseHashcash(hc); nil != err {
|
if err := UseHashcash(hc, r.Host); nil != err {
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,9 +34,9 @@ type PublicJWK struct {
|
||||||
|
|
||||||
type KVDB interface {
|
type KVDB interface {
|
||||||
Load(key interface{}) (value interface{}, ok bool, err error)
|
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)
|
Delete(key interface{}) (err error)
|
||||||
vacuum() (err error)
|
Vacuum() (err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type InspectableToken struct {
|
type InspectableToken struct {
|
||||||
|
|
328
mockid/route.go
328
mockid/route.go
|
@ -4,6 +4,7 @@ import (
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
@ -17,11 +18,14 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.coolaj86.com/coolaj86/go-mockid/kvdb"
|
||||||
"git.coolaj86.com/coolaj86/go-mockid/mockid/api"
|
"git.coolaj86.com/coolaj86/go-mockid/mockid/api"
|
||||||
"git.coolaj86.com/coolaj86/go-mockid/xkeypairs"
|
"git.coolaj86.com/coolaj86/go-mockid/xkeypairs"
|
||||||
"git.rootprojects.org/root/keypairs"
|
"git.rootprojects.org/root/keypairs"
|
||||||
"git.rootprojects.org/root/keypairs/keyfetch"
|
"git.rootprojects.org/root/keypairs/keyfetch"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
ua "github.com/mileusna/useragent"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPResponse struct {
|
type HTTPResponse struct {
|
||||||
|
@ -29,12 +33,36 @@ type HTTPResponse struct {
|
||||||
Success bool `json:"success"`
|
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
|
// Route returns an HTTP Mux containing the full API
|
||||||
func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
|
func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
|
||||||
Init()
|
Init()
|
||||||
|
|
||||||
|
contactKV := kvdb.KVDB{
|
||||||
|
Prefix: jwksPrefix + "/contacts",
|
||||||
|
Ext: "eml.json",
|
||||||
|
}
|
||||||
|
|
||||||
// TODO get from main()
|
// 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())
|
pubkey := keypairs.NewPublicKey(privkey.Public())
|
||||||
|
|
||||||
http.HandleFunc("/api/new-hashcash", func(w http.ResponseWriter, r *http.Request) {
|
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("Expires", time.Now().Format(http.TimeFormat))
|
||||||
w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store")
|
w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store")
|
||||||
w.Header().Set("Pragma", "no-cache")
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
// add reasonable security options
|
||||||
w.Header().Set("Strict-Transport-Security", "max-age=604800")
|
w.Header().Set("Strict-Transport-Security", "max-age=604800")
|
||||||
|
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
|
||||||
h := issueHashcash(w, r)
|
h := issueHashcash(w, r)
|
||||||
|
@ -60,6 +88,118 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
|
||||||
w.Write(b)
|
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) {
|
http.HandleFunc("/api/new-nonce", requireHashcash(func(w http.ResponseWriter, r *http.Request) {
|
||||||
indexURL := getBaseURL(r) + "/api/directory"
|
indexURL := getBaseURL(r) + "/api/directory"
|
||||||
w.Header().Set("Link", "<"+indexURL+">;rel=\"index\"")
|
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) {
|
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{
|
b, _ := json.Marshal(&HTTPResponse{
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
|
@ -159,14 +299,21 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := getBaseURL(r)
|
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)
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
msg, _ := json.Marshal(err.Error())
|
msg, _ := json.Marshal(err.Error())
|
||||||
fmt.Fprintf(w, `{"error":%s}`+"\n", msg)
|
fmt.Fprintf(w, `{"error":%s}`+"\n", msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w, `{ "success": true, "error": "" }%s`, "\n")
|
fmt.Fprintf(w, `{ "success": true, "error": "", "receipt":, "%s" }%s`, receipt, "\n")
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO use chi
|
// TODO use chi
|
||||||
|
@ -176,9 +323,14 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
|
||||||
http.Error(w, "invalid url path", http.StatusBadRequest)
|
http.Error(w, "invalid url path", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := parts[2]
|
secret, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if nil != err {
|
||||||
if err := checkOTP(token); 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)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
fmt.Fprintf(w, "%s", err)
|
fmt.Fprintf(w, "%s", err)
|
||||||
return
|
return
|
||||||
|
@ -420,75 +572,165 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
|
||||||
return http.DefaultServeMux
|
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) {
|
func hashOTPSecret(secret []byte) (hash string, receipt string) {
|
||||||
rnd, err := uuid.NewRandom()
|
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 {
|
if nil != err {
|
||||||
// nothing else to do if we run out of random
|
// nothing else to do if we run out of random
|
||||||
// or are on a platform that doesn't support random
|
// or are on a platform that doesn't support random
|
||||||
panic(fmt.Errorf("random bytes read failure: %w", err))
|
panic(fmt.Errorf("random bytes read failure: %w", err))
|
||||||
}
|
}
|
||||||
token := base64.RawURLEncoding.EncodeToString(rnd[:])
|
// The (double) hash becomes the file or DB id.
|
||||||
|
// Using this rather than the secret itself prevents DB / FS / compare timing attacks
|
||||||
// We hash the random value to prevent DB / FS / compare timing attacks
|
hash, receipt := hashOTPSecret(secret)
|
||||||
tokenID := sha1.Sum([]byte(token))
|
otp := OTP{
|
||||||
tokenName := base64.RawURLEncoding.EncodeToString(tokenID[:])
|
CreatedAt: time.Now(),
|
||||||
|
Email: strings.ToLower(email),
|
||||||
|
ReceiptUA: agent,
|
||||||
|
ReceiptIP: addr,
|
||||||
|
//Attempts: 0,
|
||||||
|
}
|
||||||
|
otpJSON, _ := json.Marshal(otp)
|
||||||
if err := ioutil.WriteFile(
|
if err := ioutil.WriteFile(
|
||||||
filepath.Join(tokenPrefix, tokenName+".tok.txt"),
|
filepath.Join(tokenPrefix, hash+".tok.txt"),
|
||||||
[]byte(`{"comment":"TODO: metadata goes here"}`),
|
otpJSON,
|
||||||
// keep it secret, keep it safe
|
// keep it secret, keep it safe
|
||||||
os.FileMode(0600),
|
os.FileMode(0600),
|
||||||
); nil != err {
|
); 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkOTP(token string) error {
|
type otpConsumer struct {
|
||||||
// We hash the random value to prevent DB / FS / compare timing attacks
|
secret bool
|
||||||
tokenID := sha1.Sum([]byte(token))
|
receipt bool
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO promote JWK to public... and related to an ID or email??
|
|
||||||
_ = os.Remove(tokfile)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startVerification(baseURL, contact string) error {
|
func consumeOTPSecret(email string, secret []byte, agent string, addr string) (*OTP, error) {
|
||||||
email := strings.Replace(contact, "mailto:", "", -1)
|
hash, _ := hashOTPSecret(secret)
|
||||||
|
return checkOTP(hash, email, agent, addr, otpConsumer{secret: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
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?
|
||||||
|
}
|
||||||
|
|
||||||
|
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, agent, addr string) (receipt string, err error) {
|
||||||
|
email := strings.Replace(strings.TrimPrefix(contact, "mailto:"), " ", "+", -1)
|
||||||
if "" == email {
|
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
|
// TODO check DNS for MX records
|
||||||
if !strings.Contains(email, "@") || strings.Contains(email, " \t\n") {
|
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 expect JWK in JWS/JWT
|
||||||
// TODO place validated JWK into file with token
|
// 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 {
|
if nil != err {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
subject := "Verify New Account"
|
subject := "Verify New Account"
|
||||||
// TODO go tpl
|
// TODO go tpl
|
||||||
// TODO determine OS and Browser from user agent
|
// TODO determine OS and Browser from user agent
|
||||||
text := fmt.Sprintf(
|
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.",
|
"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, token,
|
baseURL, base64.RawURLEncoding.EncodeToString(secret), ua.Name, ua.OS, ua.Device, addr,
|
||||||
)
|
)
|
||||||
if _, err = SendSimpleMessage(email, defaultFrom, subject, text, defaultReplyTo); nil != err {
|
fmt.Println("email:", text)
|
||||||
return err
|
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 {
|
func getBaseURL(r *http.Request) string {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
package-lock.json
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
dist/
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"useTabs": true
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +1,45 @@
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="google-signin-scope" content="email" />
|
||||||
|
<meta
|
||||||
|
name="google-signin-client_id"
|
||||||
|
content="291138637698-9hjbgadgkibuv9j26104aj0bg5bia30j.apps.googleusercontent.com"
|
||||||
|
/>
|
||||||
|
|
||||||
<meta name="google-signin-scope" content="email">
|
<style>
|
||||||
<meta
|
@media (prefers-color-scheme: dark) {
|
||||||
name="google-signin-client_id"
|
body {
|
||||||
content="291138637698-9hjbgadgkibuv9j26104aj0bg5bia30j.apps.googleusercontent.com"
|
background-color: #222;
|
||||||
/>
|
color: #aaa;
|
||||||
|
}
|
||||||
<style>
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
body {
|
||||||
body {
|
background-color: #222;
|
||||||
background-color: #222;
|
color: #aaa;
|
||||||
color: #aaa;
|
}
|
||||||
}
|
.container {
|
||||||
}
|
padding: 2em;
|
||||||
body {
|
}
|
||||||
background-color: #222;
|
.authn-container {
|
||||||
color: #aaa;
|
width: 300px;
|
||||||
}
|
margin: auto;
|
||||||
.container {
|
border: solid 1px #c0c0c0;
|
||||||
padding: 2em;
|
}
|
||||||
}
|
.authn-container hr {
|
||||||
</style>
|
width: 80%;
|
||||||
</head>
|
}
|
||||||
<body>
|
.link {
|
||||||
<div class="container">
|
background: none;
|
||||||
<pre><code>
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<pre><code>
|
||||||
<h1>Tokens for Testing</h1>
|
<h1>Tokens for Testing</h1>
|
||||||
Compatible with
|
Compatible with
|
||||||
|
|
||||||
|
@ -140,26 +154,110 @@ You shouldn't use it for automated testing, because it will change, but it looks
|
||||||
}
|
}
|
||||||
|
|
||||||
</code></pre>
|
</code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="g-signin2" data-scope="email" data-onsuccess="onSignIn" data-theme="dark"></div>
|
<div class="authn-flow">
|
||||||
<script>
|
<div class="authn-container authn-loading">
|
||||||
function onSignIn(googleUser) {
|
<center>⌛</center>
|
||||||
// Useful data for your client-side scripts:
|
</div>
|
||||||
var profile = googleUser.getBasicProfile();
|
|
||||||
console.log("ID: " + profile.getId()); // Don't send this directly to your server!
|
|
||||||
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:
|
<div class="authn-container authn-email">
|
||||||
var id_token = googleUser.getAuthResponse().id_token;
|
<center>
|
||||||
console.log("ID Token: " + id_token);
|
<form class="authn-form">
|
||||||
}
|
<h2>Login</h2>
|
||||||
</script>
|
<input
|
||||||
<script src="https://apis.google.com/js/platform.js" async defer></script>
|
name="username"
|
||||||
|
type="email"
|
||||||
|
placeholder="email"
|
||||||
|
value="coolaj86+noreply@gmail.com"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
<hr />
|
||||||
|
<div
|
||||||
|
class="g-signin2"
|
||||||
|
data-scope="email"
|
||||||
|
data-onsuccess="onSignIn"
|
||||||
|
data-theme="dark"
|
||||||
|
></div>
|
||||||
|
<br />
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
<div class="authn-container authn-new-user">
|
||||||
|
<center>
|
||||||
|
<h2>Choose Password</h2>
|
||||||
|
<form class="authn-form">
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
value="secret"
|
||||||
|
/><button type="button">Show</button>
|
||||||
|
<br />
|
||||||
|
<button type="submit">Create Account</button>
|
||||||
|
<br />
|
||||||
|
Already have an account?
|
||||||
|
<button class="link" type="button">
|
||||||
|
link existing account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="authn-container authn-existing">
|
||||||
|
<!-- skip this for google auth -->
|
||||||
|
<center>
|
||||||
|
<h2>Existing User</h2>
|
||||||
|
<form class="authn-form">
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
<br />
|
||||||
|
<button class="link" type="button">
|
||||||
|
forgot password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="authn-container authn-failed">
|
||||||
|
<center>
|
||||||
|
<h2>Incorrect Password</h2>
|
||||||
|
<form class="authn-form">
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
<br />
|
||||||
|
<button class="link" type="button">
|
||||||
|
forgot password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="authn-container authn-new-device">
|
||||||
|
<center>
|
||||||
|
<h2>New Device</h2>
|
||||||
|
<p>Check your email to confirm new device.</p>
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./dist/main.js"></script>
|
||||||
|
<script
|
||||||
|
src="https://apis.google.com/js/platform.js"
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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 <coolaj86@gmail.com>",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@root/keypairs": "^0.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"webpack": "^5.0.0-beta.28",
|
||||||
|
"webpack-cli": "^3.3.12"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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: []
|
||||||
|
};
|
|
@ -0,0 +1,4 @@
|
||||||
|
old.go
|
||||||
|
old_test.go
|
||||||
|
file.txt
|
||||||
|
user_agents.txt
|
|
@ -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.
|
|
@ -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 <a id="installation"></a>
|
||||||
|
```
|
||||||
|
go get github.com/mileusna/useragent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example<a id="example"></a>
|
||||||
|
|
||||||
|
```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`.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/mileusna/useragent
|
||||||
|
|
||||||
|
go 1.14
|
|
@ -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
|
||||||
|
}
|
|
@ -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 ""
|
||||||
|
}
|
|
@ -19,5 +19,7 @@ github.com/mailru/easyjson
|
||||||
github.com/mailru/easyjson/buffer
|
github.com/mailru/easyjson/buffer
|
||||||
github.com/mailru/easyjson/jlexer
|
github.com/mailru/easyjson/jlexer
|
||||||
github.com/mailru/easyjson/jwriter
|
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 v0.8.1
|
||||||
github.com/pkg/errors
|
github.com/pkg/errors
|
||||||
|
|
Loading…
Reference in New Issue