diff --git a/cmd/mailer/mailer.go b/cmd/mailer/mailer.go index 2c3c7ed..b68e887 100644 --- a/cmd/mailer/mailer.go +++ b/cmd/mailer/mailer.go @@ -16,7 +16,7 @@ func main() { /* MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx MAILGUN_DOMAIN=mail.example.com - MAILGUN_FROM="Rob the Robot " + MAILER_FROM="Rob the Robot " */ to := flag.String("to", "", "message recipient in the format of 'John Doe '") @@ -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!" diff --git a/examples/example.env b/examples/example.env new file mode 100644 index 0000000..46fbd5d --- /dev/null +++ b/examples/example.env @@ -0,0 +1,7 @@ +SALT=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +MAILGUN_DOMAIN=mail.example.com + +MAILER_FROM="Rob the Robot " +MAILER_REPLY_TO=support@example.com diff --git a/go.mod b/go.mod index 908fba3..e972ab7 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index a9cb1ee..8e4308a 100644 --- a/go.sum +++ b/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/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= diff --git a/mockid/mailgun.go b/mockid/mailgun.go new file mode 100644 index 0000000..36ce1f9 --- /dev/null +++ b/mockid/mailgun.go @@ -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 " + */ + + 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 +} diff --git a/mockid/mockid.go b/mockid/mockid.go index 98261a2..19f8a2b 100644 --- a/mockid/mockid.go +++ b/mockid/mockid.go @@ -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)