demo email verification
This commit is contained in:
parent
155c006740
commit
87494faffe
|
@ -16,7 +16,7 @@ func main() {
|
||||||
/*
|
/*
|
||||||
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
MAILGUN_DOMAIN=mail.example.com
|
MAILGUN_DOMAIN=mail.example.com
|
||||||
MAILGUN_FROM="Rob the Robot <rob.the.robot@mail.example.com>"
|
MAILER_FROM="Rob the Robot <rob.the.robot@mail.example.com>"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
to := flag.String("to", "", "message recipient in the format of 'John Doe <john@example.com>'")
|
to := flag.String("to", "", "message recipient in the format of 'John Doe <john@example.com>'")
|
||||||
|
@ -36,7 +36,7 @@ func main() {
|
||||||
|
|
||||||
domain := os.Getenv("MAILGUN_DOMAIN")
|
domain := os.Getenv("MAILGUN_DOMAIN")
|
||||||
apiKey := os.Getenv("MAILGUN_API_KEY")
|
apiKey := os.Getenv("MAILGUN_API_KEY")
|
||||||
from := os.Getenv("MAILGUN_FROM")
|
from := os.Getenv("MAILER_FROM")
|
||||||
|
|
||||||
if 0 == len(*text) {
|
if 0 == len(*text) {
|
||||||
*text = "Testing some Mailgun awesomeness!"
|
*text = "Testing some Mailgun awesomeness!"
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
SALT=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
|
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
MAILGUN_DOMAIN=mail.example.com
|
||||||
|
|
||||||
|
MAILER_FROM="Rob the Robot <rob.the.robot@mail.example.com>"
|
||||||
|
MAILER_REPLY_TO=support@example.com
|
1
go.mod
1
go.mod
|
@ -4,6 +4,7 @@ go 1.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.rootprojects.org/root/keypairs v0.5.2
|
git.rootprojects.org/root/keypairs v0.5.2
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -10,6 +10,8 @@ github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQD
|
||||||
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
||||||
github.com/go-chi/chi v4.0.0+incompatible h1:SiLLEDyAkqNnw+T/uDTf3aFB9T4FTrwMpuYrgaRcnW4=
|
github.com/go-chi/chi v4.0.0+incompatible h1:SiLLEDyAkqNnw+T/uDTf3aFB9T4FTrwMpuYrgaRcnW4=
|
||||||
github.com/go-chi/chi v4.0.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
github.com/go-chi/chi v4.0.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||||
|
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||||
|
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||||
github.com/mailgun/mailgun-go/v3 v3.6.4 h1:+cvbZRgLSHivbz/w1iWLmxVl6Bqf4geD2D7QMj4+8PE=
|
github.com/mailgun/mailgun-go/v3 v3.6.4 h1:+cvbZRgLSHivbz/w1iWLmxVl6Bqf4geD2D7QMj4+8PE=
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package mockid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mailgun "github.com/mailgun/mailgun-go/v3"
|
||||||
|
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mgDomain string
|
||||||
|
mgAPIKey string
|
||||||
|
mgFrom string
|
||||||
|
mg *mailgun.MailgunImpl
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
/*
|
||||||
|
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
MAILGUN_DOMAIN=mail.example.com
|
||||||
|
MAILER_FROM="Rob the Robot <rob.the.robot@mail.example.com>"
|
||||||
|
*/
|
||||||
|
|
||||||
|
mgDomain = os.Getenv("MAILGUN_DOMAIN")
|
||||||
|
mgAPIKey = os.Getenv("MAILGUN_API_KEY")
|
||||||
|
mgFrom = os.Getenv("MAILER_FROM")
|
||||||
|
|
||||||
|
mg = mailgun.NewMailgun(mgDomain, mgAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendSimpleMessage(to, from, subject, text, replyTo string) (string, error) {
|
||||||
|
m := mg.NewMessage(from, subject, text, to)
|
||||||
|
if 0 != len(replyTo) {
|
||||||
|
// mailgun's required "h:" prefix is added by the library
|
||||||
|
m.AddHeader("Reply-To", replyTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, id, err := mg.Send(ctx, m)
|
||||||
|
return id, err
|
||||||
|
}
|
131
mockid/mockid.go
131
mockid/mockid.go
|
@ -5,6 +5,7 @@ import (
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
@ -15,14 +16,19 @@ import (
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.rootprojects.org/root/keypairs"
|
"git.rootprojects.org/root/keypairs"
|
||||||
"git.rootprojects.org/root/keypairs/keyfetch"
|
"git.rootprojects.org/root/keypairs/keyfetch"
|
||||||
|
|
||||||
//jwt "github.com/dgrijalva/jwt-go"
|
//jwt "github.com/dgrijalva/jwt-go"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PublicJWK struct {
|
type PublicJWK struct {
|
||||||
|
@ -53,13 +59,39 @@ func (t *InspectableToken) MarshalJSON() ([]byte, error) {
|
||||||
)), nil
|
)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var nonces map[string]int64
|
var defaultFrom string
|
||||||
|
var defaultReplyTo string
|
||||||
|
|
||||||
|
//var nonces map[string]int64
|
||||||
|
//var nonCh chan string
|
||||||
|
var nonces sync.Map
|
||||||
|
var salt []byte
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
nonces = make(map[string]int64)
|
var err error
|
||||||
|
salt64 := os.Getenv("SALT")
|
||||||
|
salt, err = base64.RawURLEncoding.DecodeString(salt64)
|
||||||
|
if len(salt64) < 22 || nil != err {
|
||||||
|
panic("SALT must be set as 22+ character base64")
|
||||||
|
}
|
||||||
|
defaultFrom = os.Getenv("MAILER_FROM")
|
||||||
|
defaultReplyTo = os.Getenv("MAILER_REPLY_TO")
|
||||||
|
//nonces = make(map[string]int64)
|
||||||
|
//nonCh = make(chan string)
|
||||||
|
|
||||||
|
/*
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
nonce := <- nonCh
|
||||||
|
nonces[nonce] = time.Now().Unix()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
func Route(jwksPrefix string, privkey keypairs.PrivateKey) {
|
func Route(jwksPrefix string, privkey keypairs.PrivateKey) {
|
||||||
|
// TODO get from main()
|
||||||
|
tokPrefix := jwksPrefix
|
||||||
pubkey := keypairs.NewPublicKey(privkey.Public())
|
pubkey := keypairs.NewPublicKey(privkey.Public())
|
||||||
|
|
||||||
http.HandleFunc("/api/new-nonce", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/new-nonce", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -87,7 +119,84 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) {
|
||||||
})
|
})
|
||||||
|
|
||||||
http.HandleFunc("/api/new-account", requireNonce(func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/new-account", requireNonce(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "Not Implemented", http.StatusNotImplemented)
|
// Try to decode the request body into the struct. If there is an error,
|
||||||
|
// respond to the client with the error message and a 400 status code.
|
||||||
|
data := map[string]string{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&data)
|
||||||
|
if nil != err {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO check DNS for MX records
|
||||||
|
parts := strings.Split(data["to"], ", <>\n\r\t")
|
||||||
|
to := parts[0]
|
||||||
|
if len(parts) > 1 || !strings.Contains(to, "@") {
|
||||||
|
http.Error(w, "invalid email address", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := uuid.NewRandom()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
token64 := base64.RawURLEncoding.EncodeToString([]byte(token[:]))
|
||||||
|
// hash token to prevent fs read timing attacks
|
||||||
|
hash := sha1.Sum(append(token[:], salt...))
|
||||||
|
tokname := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
if err := ioutil.WriteFile(
|
||||||
|
filepath.Join(tokPrefix, tokname+".tok.txt"),
|
||||||
|
[]byte(`{"comment":"I have no idea..."}`),
|
||||||
|
os.FileMode(0600),
|
||||||
|
); nil != err {
|
||||||
|
http.Error(w, "database connection failed when writing verification token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := "Verify New Account"
|
||||||
|
// TODO go tpl
|
||||||
|
// TODO determine OS and Browser from user agent
|
||||||
|
baseURL := getBaseURL(r)
|
||||||
|
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, token64,
|
||||||
|
)
|
||||||
|
_, err = SendSimpleMessage(to, defaultFrom, subject, text, defaultReplyTo)
|
||||||
|
if nil != err {
|
||||||
|
// TODO neuter mailgun output
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, `{ "success": true, "error": "" }%s`, "\n")
|
||||||
|
}))
|
||||||
|
|
||||||
|
// TODO use chi
|
||||||
|
http.HandleFunc("/verify/", requireNonce(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
if 3 != len(parts) {
|
||||||
|
http.Error(w, "invalid url path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token64 := parts[2]
|
||||||
|
token, err := base64.RawURLEncoding.DecodeString(token64)
|
||||||
|
if err != nil || 0 == len(token) {
|
||||||
|
http.Error(w, "invalid url path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// hash token to prevent fs read timing attacks
|
||||||
|
hash := sha1.Sum(append(token, salt...))
|
||||||
|
tokname := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
tokfile := filepath.Join(tokPrefix, tokname+".tok.txt")
|
||||||
|
_, err = ioutil.ReadFile(tokfile)
|
||||||
|
if nil != err {
|
||||||
|
http.Error(w, "database connection failed when reading verification token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Remove(tokfile)
|
||||||
|
|
||||||
|
fmt.Fprintf(w, `{ "success": true, "error": "" }%s`, "\n")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
http.HandleFunc("/api/jwks", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -521,6 +630,7 @@ func JOSEVerify(pubkey keypairs.PublicKey, hash []byte, sig []byte) bool {
|
||||||
case *rsa.PublicKey:
|
case *rsa.PublicKey:
|
||||||
// TODO keypairs.Size(key) to detect key size ?
|
// TODO keypairs.Size(key) to detect key size ?
|
||||||
//alg := "SHA256"
|
//alg := "SHA256"
|
||||||
|
// TODO: this hasn't been tested yet
|
||||||
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash, sig); nil != err {
|
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash, sig); nil != err {
|
||||||
verified = true
|
verified = true
|
||||||
}
|
}
|
||||||
|
@ -566,7 +676,8 @@ func issueNonce(w http.ResponseWriter, r *http.Request) {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
nonce := base64.RawURLEncoding.EncodeToString(b)
|
nonce := base64.RawURLEncoding.EncodeToString(b)
|
||||||
nonces[nonce] = time.Now().Unix()
|
//nonCh <- nonce
|
||||||
|
nonces.Store(nonce, time.Now())
|
||||||
|
|
||||||
w.Header().Set("Replay-Nonce", nonce)
|
w.Header().Set("Replay-Nonce", nonce)
|
||||||
}
|
}
|
||||||
|
@ -575,8 +686,13 @@ func requireNonce(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
nonce := r.Header.Get("Replay-Nonce")
|
nonce := r.Header.Get("Replay-Nonce")
|
||||||
// TODO expire nonces every so often
|
// TODO expire nonces every so often
|
||||||
t := nonces[nonce]
|
//t := nonces[nonce]
|
||||||
if 0 == t {
|
var t time.Time
|
||||||
|
tmp, ok := nonces.Load(nonce)
|
||||||
|
if ok {
|
||||||
|
t = tmp.(time.Time)
|
||||||
|
}
|
||||||
|
if !ok || time.Now().Sub(t) > 15*time.Minute {
|
||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
`{ "error": "invalid or expired nonce", "error_code": "ENONCE" }`,
|
`{ "error": "invalid or expired nonce", "error_code": "ENONCE" }`,
|
||||||
|
@ -585,7 +701,8 @@ func requireNonce(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(nonces, nonce)
|
//delete(nonces, nonce)
|
||||||
|
nonces.Delete(nonce)
|
||||||
issueNonce(w, r)
|
issueNonce(w, r)
|
||||||
|
|
||||||
next(w, r)
|
next(w, r)
|
||||||
|
|
Loading…
Reference in New Issue