Files
foundry-core/internal/auth/email.go
codex-bot f69575d48f
Foundry Go checks / check (push) Failing after 2m1s
Foundry Go checks / check (pull_request) Failing after 1m50s
Serialize email verification and resend to preserve registration state
2026-11-05 18:53:50 +08:00

218 lines
7.1 KiB
Go

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()
if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil {
return err
}
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(), "SELECT email FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now()", project, digest(input.Token)).Scan(&email)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 400, "invalid_verification")
}
if err != nil {
return err
}
if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil {
return err
}
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
}