Browse Source

demo email verification

master
AJ ONeal 9 months ago
parent
commit
87494faffe
6 changed files with 182 additions and 9 deletions
  1. +2
    -2
      cmd/mailer/mailer.go
  2. +7
    -0
      examples/example.env
  3. +1
    -0
      go.mod
  4. +2
    -0
      go.sum
  5. +46
    -0
      mockid/mailgun.go
  6. +124
    -7
      mockid/mockid.go

+ 2
- 2
cmd/mailer/mailer.go View File

@ -16,7 +16,7 @@ func main() {
/*
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
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>'")
@ -36,7 +36,7 @@ func main() {
domain := os.Getenv("MAILGUN_DOMAIN")
apiKey := os.Getenv("MAILGUN_API_KEY")
from := os.Getenv("MAILGUN_FROM")
from := os.Getenv("MAILER_FROM")
if 0 == len(*text) {
*text = "Testing some Mailgun awesomeness!"

+ 7
- 0
examples/example.env View File

@ -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
- 0
go.mod View File

@ -4,6 +4,7 @@ go 1.13
require (
git.rootprojects.org/root/keypairs v0.5.2
github.com/google/uuid v1.1.1
github.com/joho/godotenv v1.3.0
github.com/mailgun/mailgun-go/v3 v3.6.4
)

+ 2
- 0
go.sum View File

@ -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/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/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/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/mailgun/mailgun-go/v3 v3.6.4 h1:+cvbZRgLSHivbz/w1iWLmxVl6Bqf4geD2D7QMj4+8PE=

+ 46
- 0
mockid/mailgun.go View File

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

+ 124
- 7
mockid/mockid.go View File

@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
@ -15,14 +16,19 @@ import (
"math/big"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.rootprojects.org/root/keypairs"
"git.rootprojects.org/root/keypairs/keyfetch"
//jwt "github.com/dgrijalva/jwt-go"
"github.com/google/uuid"
)
type PublicJWK struct {
@ -53,13 +59,39 @@ func (t *InspectableToken) MarshalJSON() ([]byte, error) {
)), 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() {
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) {
// TODO get from main()
tokPrefix := jwksPrefix
pubkey := keypairs.NewPublicKey(privkey.Public())
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.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) {
@ -521,6 +630,7 @@ func JOSEVerify(pubkey keypairs.PublicKey, hash []byte, sig []byte) bool {
case *rsa.PublicKey:
// TODO keypairs.Size(key) to detect key size ?
//alg := "SHA256"
// TODO: this hasn't been tested yet
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash, sig); nil != err {
verified = true
}
@ -566,7 +676,8 @@ func issueNonce(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
nonce := base64.RawURLEncoding.EncodeToString(b)
nonces[nonce] = time.Now().Unix()
//nonCh <- nonce
nonces.Store(nonce, time.Now())
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) {
nonce := r.Header.Get("Replay-Nonce")
// TODO expire nonces every so often
t := nonces[nonce]
if 0 == t {
//t := nonces[nonce]
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(
w,
`{ "error": "invalid or expired nonce", "error_code": "ENONCE" }`,
@ -585,7 +701,8 @@ func requireNonce(next http.HandlerFunc) http.HandlerFunc {
return
}
delete(nonces, nonce)
//delete(nonces, nonce)
nonces.Delete(nonce)
issueNonce(w, r)
next(w, r)

Loading…
Cancel
Save