update errors and iframe

This commit is contained in:
AJ ONeal 2020-09-17 07:28:55 +00:00
parent 881bf97334
commit 84e1863da2
5 changed files with 72 additions and 510 deletions

View File

@ -11,6 +11,7 @@ import (
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"path/filepath"
@ -28,8 +29,30 @@ import (
ua "github.com/mileusna/useragent"
)
var errTokenNotVerified = apiError{"token has not been verified"}
var errUsedToken = apiError{"token has already been used"}
var errInvalidEmail = apiError{"invalid email address"}
// API Errors
type serverError struct {
error string
}
func (e serverError) Error() string {
return e.error
}
type apiError struct {
error string
}
func (e apiError) Error() string {
return e.error
}
type HTTPResponse struct {
Error string `json:"error"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
Success bool `json:"success"`
}
@ -135,13 +158,22 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
return
}
contact, err = lintEmail(contact)
if nil != err {
b, _ := json.Marshal(&HTTPResponse{
Error: err.Error(),
Code: "E_USER",
})
w.Write(b)
return
}
_, ok, err := contactKV.Load(contact)
if nil != err {
fmt.Fprintf(os.Stderr, "meta: error loading contact: %s\n", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if ok {
b, _ := json.Marshal(&HTTPResponse{
Success: true,
@ -152,6 +184,7 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
b, _ := json.Marshal(&HTTPResponse{
Error: "not found",
Code: "E_USER",
})
w.Write(b)
})
@ -217,6 +250,13 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler {
otp, err = consumeOTPReceipt("TODO", receipt, agent, addr)
}
if nil != err {
if errTokenNotVerified == err {
b, _ := json.Marshal(&HTTPResponse{
Error: err.Error(),
})
w.Write(b)
return
}
// TODO propagate error types
http.Error(w, "Bad Request", http.StatusBadRequest)
fmt.Fprintf(w, "%s", err)
@ -821,7 +861,7 @@ func newOTP(email string, agent string, addr string) (id string, secret []byte,
// keep it secret, keep it safe
os.FileMode(0600),
); nil != err {
return "", nil, errors.New("database connection failed when writing verification token")
return "", nil, serverError{"database connection failed when writing verification token"}
}
return receipt, secret, nil
}
@ -847,12 +887,12 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error
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")
return nil, serverError{"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")
return nil, serverError{"database verification token parse failed"}
}
if 0 == subtle.ConstantTimeCompare([]byte(otp.Email), []byte(email)) {
@ -862,7 +902,7 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error
if consume.secret {
if !otp.SecretUsed.IsZero() {
return nil, errors.New("token has already been used")
return nil, errUsedToken
}
otp.SecretUsed = time.Now()
if addr != otp.ReceiptIP {
@ -874,10 +914,10 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error
}
} else if consume.receipt {
if otp.SecretUsed.IsZero() {
return nil, errors.New("token has not been verified")
return nil, errTokenNotVerified
}
if !otp.ReceiptUsed.IsZero() {
return nil, errors.New("token has already been used")
return nil, errUsedToken
}
otp.ReceiptUsed = time.Now()
}
@ -890,24 +930,38 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error
// keep it secret, keep it safe
os.FileMode(0600),
); nil != err {
return nil, errors.New("database connection failed when consuming token")
return nil, serverError{"database connection failed when consuming token"}
}
}
fmt.Println("THE TOKEN IS GOOD. GOOD!!")
return &otp, nil
}
func lintEmail(email string) (string, error) {
// TODO check DNS for MX records
parts := strings.Split(email, "@")
domain := parts[1]
if 2 != len(parts) || strings.Contains(email, " \t\n") {
return "", errInvalidEmail
}
mxs, err := net.LookupMX(domain)
if len(mxs) < 1 || nil != err {
// TODO it possible in some cases that this
// could be a network error
return "", errInvalidEmail
}
return strings.ToLower(email), nil
}
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\"]")
}
// TODO check DNS for MX records
if !strings.Contains(email, "@") || strings.Contains(email, " \t\n") {
return "", errors.New("invalid email address")
email, err = lintEmail(email)
if nil != err {
return "", err
}
// TODO expect JWK in JWS/JWT
@ -922,9 +976,10 @@ func startVerification(baseURL, contact, agent, addr string) (receipt string, er
subject := "Verify New Account"
// TODO go tpl
// TODO determine OS and Browser from user agent
page := "pocket/iframe.html"
text := fmt.Sprintf(
"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,
"It looks like you just tried to register a new Pocket ID account.\n\n Verify account: %s/%s#/verify/%s\n\n%s on %s %s from %s\n\nNot you? Just ignore this message.",
baseURL, page, base64.RawURLEncoding.EncodeToString(secret), ua.Name, ua.OS, ua.Device, addr,
)
fmt.Println("email:", text)
if !strings.Contains(contact, "+noreply") {

View File

@ -1,79 +0,0 @@
'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;
}
}

View File

@ -1,214 +1,3 @@
'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');
});
});
function verifyNewDevice() {
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) {
// this should have a token we can inspect
// and return to the calling application.
console.log(resp);
window.alert('all set!');
});
});
}
$('.authn-existing form').addEventListener('submit', function (ev) {
ev.preventDefault();
ev.stopPropagation();
setFlow('.authn-container', '.authn-loading');
verifyNewDevice();
});
$('.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');
verifyNewDevice();
});
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
}
require('./pocket/consumer.js');

View File

@ -1,118 +0,0 @@
'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);
};

View File

@ -1,85 +0,0 @@
'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;
};