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 }