diff --git a/.env.example b/.env.example index c879d5b..c752c26 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,10 @@ GOOGLE_CLIENT_SECRET=replace-me FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}' HOST=127.0.0.1 PORT=8080 + +# Optional email verification through TLS SMTP. +SMTP_HOST=smtpdm.aliyun.com +SMTP_PORT=465 +SMTP_USERNAME=noreply@notice.entroia.com +SMTP_PASSWORD=replace-me +SMTP_FROM=noreply@notice.entroia.com diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 0d3ce4c..6b25a5e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "log" "net" @@ -46,7 +47,12 @@ func main() { } db := stdlib.OpenDB(*config) defer db.Close() - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret) + email, err := auth.EmailConfigFromEnv() + if err != nil { + log.Fatal(err) + } + go email.Run(context.Background(), db) + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email) if err != nil { log.Fatal(err) } diff --git a/db/002-platform.sql b/db/002-platform.sql index dc8af41..3721b0d 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -52,6 +52,8 @@ CREATE TABLE email_outbox ( attempts integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL DEFAULT now(), lease_until timestamptz, + lease_id uuid, + expires_at timestamptz NOT NULL, sent_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); diff --git a/deploy/release.sh b/deploy/release.sh index eddcf71..3636858 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -44,6 +44,10 @@ docker network inspect "$network" >/dev/null release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" +mail_env=() +if [[ -s "$foundry/mail.env" ]]; then + mail_env=(--env-file "$foundry/mail.env") +fi codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then @@ -64,7 +68,7 @@ if [[ -d "$codex_dir" ]]; then ) fi -docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a1ef605..e2d0428 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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": diff --git a/internal/auth/email.go b/internal/auth/email.go new file mode 100644 index 0000000..c69707a --- /dev/null +++ b/internal/auth/email.go @@ -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 +} diff --git a/internal/auth/mailer.go b/internal/auth/mailer.go new file mode 100644 index 0000000..77efda5 --- /dev/null +++ b/internal/auth/mailer.go @@ -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 +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index bd7b126..ead8ccc 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -7,10 +7,13 @@ import ( "crypto/sha256" "database/sql" "encoding/base64" + "encoding/hex" "encoding/json" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "net/http" "net/http/httptest" "os" + "regexp" "testing" _ "github.com/jackc/pgx/v5/stdlib" @@ -29,7 +32,7 @@ func TestOnboardingAndSharing(t *testing.T) { ctx := context.Background() var raw [12]byte rand.Read(raw[:]) - project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:]) + project := "test-" + hex.EncodeToString(raw[:]) accounts := []string{} tokens := []string{} for range 3 { @@ -52,9 +55,15 @@ func TestOnboardingAndSharing(t *testing.T) { defer func() { _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project) }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}) + if err != nil { + t.Fatal(err) + } + mux.Handle("/", authHandler) Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) @@ -110,6 +119,26 @@ func TestOnboardingAndSharing(t *testing.T) { contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) + email := "fixture@example.invalid" + password := "a-long-fixture-password" + call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) + call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401) + var body string + if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing email verification link") + } + verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201) + call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400) + call("GET", base+"/profile", verified["access_token"].(string), nil, 200) + call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401) + loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200) + call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204) + call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401) + var count int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { t.Fatalf("material grant missing %v", err)