Browse Source

first draft of login exchanges complete

master
AJ ONeal 5 months ago
parent
commit
6981b852d0
23 changed files with 1635 additions and 95 deletions
  1. +1
    -0
      go.mod
  2. +2
    -0
      go.sum
  3. +3
    -3
      mockid/hashcash.go
  4. +2
    -2
      mockid/mockid.go
  5. +283
    -41
      mockid/route.go
  6. +1
    -0
      public/.gitignore
  7. +16
    -0
      public/.jshintrc
  8. +1
    -0
      public/.prettierignore
  9. +8
    -0
      public/.prettierrc
  10. +79
    -0
      public/hashcash.js
  11. +147
    -49
      public/index.html
  12. +199
    -0
      public/main.js
  13. +34
    -0
      public/package.json
  14. +118
    -0
      public/pocketid.js
  15. +85
    -0
      public/request.js
  16. +19
    -0
      public/webpack.config.js
  17. +4
    -0
      vendor/github.com/mileusna/useragent/.gitignore
  18. +21
    -0
      vendor/github.com/mileusna/useragent/LICENSE
  19. +97
    -0
      vendor/github.com/mileusna/useragent/README.md
  20. +3
    -0
      vendor/github.com/mileusna/useragent/go.mod
  21. +76
    -0
      vendor/github.com/mileusna/useragent/is.go
  22. +434
    -0
      vendor/github.com/mileusna/useragent/ua.go
  23. +2
    -0
      vendor/modules.txt

+ 1
- 0
go.mod View File

@ -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
)

+ 2
- 0
go.sum View File

@ -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=

+ 3
- 3
mockid/hashcash.go View File

@ -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
}

+ 2
- 2
mockid/mockid.go View File

@ -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 {

+ 283
- 41
mockid/route.go View File

@ -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 {

+ 1
- 0
public/.gitignore View File

@ -0,0 +1 @@
package-lock.json

+ 16
- 0
public/.jshintrc View File

@ -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
}

+ 1
- 0
public/.prettierignore View File

@ -0,0 +1 @@
dist/

+ 8
- 0
public/.prettierrc View File

@ -0,0 +1,8 @@
{
"bracketSpacing": true,
"printWidth": 80,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"useTabs": true
}

+ 79
- 0
public/hashcash.js View File

@ -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;
}
}

+ 147
- 49
public/index.html View File

@ -1,31 +1,45 @@
<html>
<head>
<meta name="google-signin-scope" content="email">
<meta
name="google-signin-client_id"
content="291138637698-9hjbgadgkibuv9j26104aj0bg5bia30j.apps.googleusercontent.com"
/>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: #222;
color: #aaa;
}
}
body {
background-color: #222;
color: #aaa;
}
.container {
padding: 2em;
}
</style>
</head>
<body>
<div class="container">
<pre><code>
<head>
<meta name="google-signin-scope" content="email" />
<meta
name="google-signin-client_id"
content="291138637698-9hjbgadgkibuv9j26104aj0bg5bia30j.apps.googleusercontent.com"
/>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: #222;
color: #aaa;
}
}
body {
background-color: #222;
color: #aaa;
}
.container {
padding: 2em;
}
.authn-container {
width: 300px;
margin: auto;
border: solid 1px #c0c0c0;
}
.authn-container hr {
width: 80%;
}
.link {
background: none;
border: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
</style>
</head>
<body>
<div class="container">
<pre><code>
<h1>Tokens for Testing</h1>
Compatible with
@ -140,26 +154,110 @@ You shouldn't use it for automated testing, because it will change, but it looks
}
</code></pre>
</div>
<div class="g-signin2" data-scope="email" data-onsuccess="onSignIn" data-theme="dark"></div>
<script>
function onSignIn(googleUser) {
// Useful data for your client-side scripts:
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:
var id_token = googleUser.getAuthResponse().id_token;
console.log("ID Token: " + id_token);
}
</script>
<script src="https://apis.google.com/js/platform.js" async defer></script>
</body>
</div>
<div class="authn-flow">
<div class="authn-container authn-loading">
<center></center>
</div>
<div class="authn-container authn-email">
<center>
<form class="authn-form">
<h2>Login</h2>
<input
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>
<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>

+ 199
- 0
public/main.js View File

@ -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
}

+ 34
- 0
public/package.json View File

@ -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"
}
}

+ 118
- 0
public/pocketid.js View File

@ -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);
};

+ 85
- 0
public/request.js View File

@ -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;
};

+ 19
- 0
public/webpack.config.js View File

@ -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: []
};

+ 4
- 0
vendor/github.com/mileusna/useragent/.gitignore View File

@ -0,0 +1,4 @@
old.go
old_test.go
file.txt
user_agents.txt

+ 21
- 0
vendor/github.com/mileusna/useragent/LICENSE View File

@ -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.

+ 97
- 0
vendor/github.com/mileusna/useragent/README.md View File

@ -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`.

+ 3
- 0
vendor/github.com/mileusna/useragent/go.mod View File

@ -0,0 +1,3 @@
module github.com/mileusna/useragent
go 1.14

+ 76
- 0
vendor/github.com/mileusna/useragent/is.go View File

@ -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
}

+ 434
- 0
vendor/github.com/mileusna/useragent/ua.go View File

@ -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")