Implement verified email registration and queued TLS delivery
Foundry Go checks / check (push) Successful in 1m52s
Foundry Go checks / check (pull_request) Successful in 1m53s

This commit is contained in:
2026-11-05 18:31:09 +08:00
parent 1b756d36ce
commit 0d0521f13d
8 changed files with 381 additions and 6 deletions
+13 -3
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"io"
"log"
"net/http"
@@ -26,10 +27,17 @@ type service struct {
projects map[string]*url.URL
redirectURI, clientID, secret string
google *http.Client
email *EmailConfig
passwordSlots chan struct{}
dummyPassword []byte
}
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) {
s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) {
dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
for name, raw := range callbacks {
u, err := url.Parse(raw)
@@ -120,8 +128,10 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
switch {
case r.Method == http.MethodOptions:
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Foundry-Code, X-Foundry-Verifier")
reply(w, 204, nil)
case r.Method == http.MethodPost && (action == "register" || action == "verify-email" || action == "login"):
return s.emailAction(w, r, project, action)
case r.Method == http.MethodPost && action == "exchange":
return s.exchange(w, r, project)
case r.Method == http.MethodGet && action == "me":
+204
View File
@@ -0,0 +1,204 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/mail"
"strings"
"golang.org/x/crypto/bcrypt"
)
func emailAddress(value string) (string, bool) {
value = strings.ToLower(strings.TrimSpace(value))
address, err := mail.ParseAddress(value)
return value, err == nil && address.Address == value && len(value) <= 254
}
func emailBody(w http.ResponseWriter, r *http.Request, input any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
decoder.DisallowUnknownFields()
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
_ = reject(w, 400, "invalid_request")
return false
}
return true
}
func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `
INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes')
ON CONFLICT(key_hash) DO UPDATE SET
attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END,
expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END
RETURNING attempts
`, digest(key)).Scan(&count)
return count <= limit, err
}
func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error {
if action == "verify-email" {
return s.verifyEmail(w, r, project)
}
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if !emailBody(w, r, &input) {
return nil
}
email, valid := emailAddress(input.Email)
if !valid || len(input.Password) < 12 || len(input.Password) > 72 {
return reject(w, 400, "invalid_credentials_format")
}
limit := 10
if action == "register" {
if s.email == nil {
return reject(w, 503, "email_not_configured")
}
limit = 3
}
allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit)
if err != nil {
return err
}
if !allowed {
w.Header().Set("Retry-After", "900")
return reject(w, 429, "rate_limited")
}
select {
case s.passwordSlots <- struct{}{}:
defer func() { <-s.passwordSlots }()
default:
w.Header().Set("Retry-After", "2")
return reject(w, 429, "auth_busy")
}
if action == "register" {
return s.registerEmail(w, r, project, email, input.Password)
}
var account, hash string
err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash)
missing := errors.Is(err, sql.ErrNoRows)
if err != nil && !missing {
return err
}
passwordHash := []byte(hash)
if missing {
passwordHash = s.dummyPassword
}
if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing {
return reject(w, 401, "invalid_credentials")
}
session := token()
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
if err != nil {
return err
}
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
return nil
}
func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
verification := token()
result, err := tx.ExecContext(r.Context(), `
INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at)
SELECT $1,$2,$3,$4,now()+interval '30 minutes'
WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2)
ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at
`, project, email, string(hash), digest(verification))
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n > 0 {
destination := *s.projects[project]
destination.Fragment = "verify_email=" + verification
body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email."
_, err = tx.ExecContext(r.Context(), `
INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes')
`, project, email, body)
if err != nil {
return err
}
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 202, payload{"status": "verification_requested"})
return nil
}
func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error {
var input struct {
Token string `json:"token"`
}
if !emailBody(w, r, &input) {
return nil
}
if !tokenPattern.MatchString(input.Token) {
return reject(w, 400, "invalid_verification")
}
allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10)
if err != nil {
return err
}
if !allowed {
return reject(w, 429, "rate_limited")
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
var email, hash string
err = tx.QueryRowContext(r.Context(), `
DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash
`, project, digest(input.Token)).Scan(&email, &hash)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 400, "invalid_verification")
}
if err != nil {
return err
}
var account string
err = tx.QueryRowContext(r.Context(), `
WITH identity AS(SELECT gen_random_uuid() AS id)
INSERT INTO project_accounts(project,id,email,username)
SELECT $1,id,$2,
left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7)
|| '_' || replace(id::text,'-','') FROM identity RETURNING id::text
`, project, email).Scan(&account)
if err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash)
if err != nil {
return err
}
session := token()
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
return nil
}
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"log"
"net"
"net/mail"
"net/smtp"
"os"
"strings"
"time"
)
type EmailConfig struct {
Host, Port, Username, Password, From string
}
func EmailConfigFromEnv() (*EmailConfig, error) {
c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")}
if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" {
return nil, nil
}
address, err := mail.ParseAddress(c.From)
if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") {
return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required")
}
return c, nil
}
func (c *EmailConfig) send(recipient, subject, body string) error {
if strings.ContainsAny(recipient+subject, "\r\n") {
return errors.New("invalid mail headers")
}
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12})
if err != nil {
return err
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(30 * time.Second))
client, err := smtp.NewClient(connection, c.Host)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
return err
}
if err = client.Mail(c.From); err != nil {
return err
}
if err = client.Rcpt(recipient); err != nil {
return err
}
writer, err := client.Data()
if err != nil {
return err
}
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body)
if _, err = writer.Write([]byte(message)); err != nil {
return err
}
if err = writer.Close(); err != nil {
return err
}
return client.Quit()
}
func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) {
if c == nil {
return
}
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("email delivery failed (%T)", err)
}
}
}
}
func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error {
var id, lease, recipient, subject, body string
var attempts int
err := db.QueryRowContext(ctx, `
UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1
WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10
AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now())
ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id::text,lease_id::text,recipient,subject,body,attempts
`).Scan(&id, &lease, &recipient, &subject, &body, &attempts)
if err != nil {
return err
}
sendErr := c.send(recipient, subject, body)
if sendErr == nil {
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease)
return err
}
delay := 30 * (1 << min(attempts, 7))
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay)
if err != nil {
return err
}
return sendErr
}