[LON-CAPA-cvs] cvs: loncom /interface createaccount.pm domainprefs.pm loncommon.pm lonconfigsettings.pm lonsupportreq.pm resetpw.pm

raeburn raeburn at source.lon-capa.org
Mon Dec 8 21:29:03 EST 2025


raeburn		Tue Dec  9 02:29:03 2025 EDT

  Modified files:              
    /loncom/interface	domainprefs.pm createaccount.pm loncommon.pm 
                     	lonconfigsettings.pm lonsupportreq.pm resetpw.pm 
  Log:
  - Supported third party Captcha services now recaptcha v2 and v3, hcaptcha, 
    and turnstile.
  
  
-------------- next part --------------
Index: loncom/interface/domainprefs.pm
diff -u loncom/interface/domainprefs.pm:1.453 loncom/interface/domainprefs.pm:1.454
--- loncom/interface/domainprefs.pm:1.453	Wed Aug 13 19:13:09 2025
+++ loncom/interface/domainprefs.pm	Tue Dec  9 02:29:03 2025
@@ -1,7 +1,7 @@
 # The LearningOnline Network with CAPA
 # Handler to set domain-wide configuration settings
 #
-# $Id: domainprefs.pm,v 1.453 2025/08/13 19:13:09 raeburn Exp $
+# $Id: domainprefs.pm,v 1.454 2025/12/09 02:29:03 raeburn Exp $
 #
 # Copyright Michigan State University Board of Trustees
 #
@@ -10478,10 +10478,14 @@
 
 sub captcha_choice {
     my ($context,$settings,$itemcount,$customcss,$rowstyle) = @_;
-    my ($keyentry,$currpub,$currpriv,%checked,$rowname,$pubtext,$privtext,
-        $vertext,$currver);
+    my ($currpub,$currpriv,$currtype,$currver,%checked,$rowname,
+        $dispprov,$dispver,$dispkeys,$threshold,$threshtext,$threshentry);
     my %lt = &captcha_phrases();
-    $keyentry = 'hidden';
+    $threshtext = '';
+    $threshentry = 'hidden';
+    $dispkeys = 'none';
+    $dispprov = 'none';
+    $dispver = 'none';
     my $colspan=2;
     if ($context eq 'cancreate') {
         $rowname = &mt('CAPTCHA validation');
@@ -10497,14 +10501,24 @@
         } else {
             $checked{'original'} = ' checked="checked"';
         }
-        if ($settings->{'captcha'} eq 'recaptcha') {
-            $pubtext = $lt{'pub'};
-            $privtext = $lt{'priv'};
-            $keyentry = 'text';
-            $vertext = $lt{'ver'};
-            $currver = $settings->{'recaptchaversion'};
-            if ($currver ne '2') {
-                $currver = 1;
+        if (($settings->{'captcha'} eq 'recaptcha') ||
+            ($settings->{'captcha'} eq 'hcaptcha') ||
+            ($settings->{'captcha'} eq 'turnstile')) {
+            $checked{'successor'} = ' checked="checked"';
+            $currtype = $settings->{'captcha'};
+            $dispkeys = 'block';
+            $dispprov = 'block';
+            if ($settings->{'captcha'} eq 'recaptcha') {
+                $dispver = 'block';
+                $currver = $settings->{'recaptchaversion'};
+                if ($currver eq '1') {
+                    $currver = 2;
+                }
+                if ($currver eq '3') {
+                    $threshold = $settings->{'captchathreshold'};
+                    $threshtext = $lt{'threshold'};
+                    $threshentry = 'text';
+                }
             }
         }
         if (ref($settings->{'recaptchakeys'}) eq 'HASH') {
@@ -10530,32 +10544,61 @@
     }
     my $output = '<tr'.$css_class.'>'.
                  '<td class="LC_left_item">'.$rowname.'</td><td class="LC_left_item" colspan="'.$colspan.'">'."\n".
-                 '<table><tr><td>'."\n";
-    foreach my $option ('original','recaptcha','notused') {
+                 '<table><tr><td><fieldset class="LC_captcha"><legend>'.$lt{'captcha'}.'</legend>'."\n";
+    foreach my $option ('original','successor','notused') {
         $output .= '<span class="LC_nobreak"><label><input type="radio" name="'.$context.'_captcha" value="'.
-                   $option.'" '.$checked{$option}.' onchange="javascript:updateCaptcha('."this,'$context'".');" />'.
+                   $option.'" '.$checked{$option}.' onchange="javascript:updateCaptcha('."'captcha','$context',this.form".');" />'.
                    $lt{$option}.'</label></span>';
         unless ($option eq 'notused') {
             $output .= (' 'x2)."\n";
         }
     }
 #
-# Note: If reCAPTCHA is to be used for LON-CAPA servers in a domain, a domain coordinator should visit:
-# https://www.google.com/recaptcha and generate a Public and Private key. For domains with multiple
-# servers a single key pair will be used for all servers, so the internet domain (e.g., yourcollege.edu)
-# specified for use with the key should be broad enough to accommodate all servers in the LON-CAPA domain.
+# Note: If reCaptcha is to be used for LON-CAPA servers in a domain, a domain coordinator should visit:
+# https://cloud.google.com/security/products/recaptcha and generate a site key and secret key. For domains
+# with multiple servers a single key pair will be used for all servers, so the internet domain
+# (e.g., yourcollege.edu) specified for use with the key should be broad enough to accommodate all servers
+# in the LON-CAPA domain. If a change is made from using reCaptcha v2 to using v3, then a new pair
+# needs to be generated via the Google Cloud dashboard.
+#
+# Similarly, if hCaptcha is to be used, a site key and secret key will need to be generated by adding a
+# new site at https://dashboard.hcaptcha.com/, and if Turnstile is to be used a site key and secret key
+# will need to be created by visiting https://dash.cloudflare.com and adding a new Turnstile widget.
 #
-    $output .= '</td></tr>'."\n".
+    $output .= '</fieldset></td></tr>'."\n".
                '<tr><td class="LC_zero_height">'."\n".
-               '<span class="LC_nobreak"><span id="'.$context.'_recaptchapubtxt">'.$pubtext.'</span> '."\n".
-               '<input type="'.$keyentry.'" id="'.$context.'_recaptchapub" name="'.$context.'_recaptchapub" value="'.
-               $currpub.'" size="40" /></span><br />'."\n".
-               '<span class="LC_nobreak"><span id="'.$context.'_recaptchaprivtxt">'.$privtext.'</span> '."\n".
-               '<input type="'.$keyentry.'" id="'.$context.'_recaptchapriv" name="'.$context.'_recaptchapriv" value="'.
-               $currpriv.'" size="40" /></span><br />'.
-               '<span class="LC_nobreak"><span id="'.$context.'_recaptchavertxt">'.$vertext.'</span> '."\n".
-               '<input type="'.$keyentry.'" id="'.$context.'_recaptchaversion" name="'.$context.'_recaptchaversion" value="'.
-               $currver.'" size="3" /></span><br />'.
+               '<fieldset class="LC_captcha" id="'.$context.'_recaptchatype" style="display:'.$dispprov.'">'.
+               '<legend>'.$lt{'type'}.'</legend>'."\n";
+    foreach my $type ('recaptcha','hcaptcha','turnstile') {
+        my $provider = $lt{$type};
+        my $checked;
+        if ($currtype eq $type) {
+            $checked = ' checked="checked"';
+        }
+        $output .= '<label><input type="radio" name="'.$context.'_captchaprovider" value="'.$type.'"'.$checked.' onchange="javascript:updateCaptchaProv('."'captchaprovider','$context',this.form".');" />'.$provider.'</label>   ';
+    }
+    $output .= '</fieldset><br />'.
+               '<fieldset class="LC_captcha" id="'.$context.'_recaptchaver" style="display:'.$dispver.'"><legend>'.$lt{'ver'}.'</legend>'."\n".
+               '<span class="LC_nobreak">';
+    foreach my $poss ('2','3') {
+        my $checked;
+        if ($currver eq $poss) {
+            $checked = ' checked="checked"'
+        }
+        $output .= '<label><input type="radio" name="'.$context.'_recaptchaversion" value="'.$poss.'"'.$checked.' onchange="javascript:updateRecapVer('."'recaptchaversion','$context',this.form".');" />'.$poss.'</label>  ';
+    }
+    $output .= '<span class="LC_nobreak">'.
+               '<span id="'.$context.'_recaptchathrtxt">'.$threshtext.'</span>'."\n".
+               ' <input id="'.$context.'_recaptchathresh" type="'.$threshentry.'" size="3"'.
+               ' name="'.$context.'_captchathreshold" value="'.$threshold.'" /></span></span>'."\n".
+               '</fieldset><br />'.
+               '<fieldset class="LC_captcha" id="'.$context.'_recaptchakeys" style="display:'.$dispkeys.'"><legend>'.$lt{'keys'}.'</legend>'."\n".
+               '<span class="LC_nobreak">'.$lt{'pub'}.': '.
+               '<input type="text" name="'.$context.'_recaptchapub" value="'.$currpub.'" size="40" />'.
+               '</span><br />'."\n".
+               '<span class="LC_nobreak">'.$lt{'priv'}.': '.
+               '<input type="text" name="'.$context.'_recaptchapriv" value="'.$currpriv.'" size="40" /></span>'."\n".
+               '</fieldset><br />'."\n".
                '</td></tr></table>'."\n".
                '</td></tr>';
     return $output;
@@ -13036,16 +13079,16 @@
                             $pubkey = $loginhash{'login'}{$item}{'public'};
                             $privkey = $loginhash{'login'}{$item}{'private'};
                         }
-                        my $chgtxt .= &mt('ReCAPTCHA keys changes').'<ul>';
+                        my $chgtxt .= &mt('CAPTCHA keys changes').'<ul>';
                         if (!$pubkey) {
-                            $chgtxt .= '<li>'.&mt('Public key deleted').'</li>';
+                            $chgtxt .= '<li>'.&mt('Site key deleted').'</li>';
                         } else {
-                            $chgtxt .= '<li>'.&mt('Public key set to [_1]',$pubkey).'</li>';
+                            $chgtxt .= '<li>'.&mt('Site key set to [_1]',$pubkey).'</li>';
                         }
                         if (!$privkey) {
-                            $chgtxt .= '<li>'.&mt('Private key deleted').'</li>';
+                            $chgtxt .= '<li>'.&mt('Secret key deleted').'</li>';
                         } else {
-                            $chgtxt .= '<li>'.&mt('Private key set to [_1]',$privkey).'</li>';
+                            $chgtxt .= '<li>'.&mt('Secret key set to [_1]',$privkey).'</li>';
                         }
                         $chgtxt .= '</ul>';
                         $resulttext .= '<li>'.$chgtxt.'</li>';
@@ -13053,7 +13096,15 @@
                 } elsif ($item eq 'recaptchaversion') {
                     if (ref($loginhash{'login'}) eq 'HASH') {
                         if ($loginhash{'login'}{'captcha'} eq 'recaptcha') {
-                            $resulttext .= '<li>'.&mt('ReCAPTCHA for helpdesk form set to version [_1]',$loginhash{'login'}{'recaptchaversion'}).
+                            $resulttext .= '<li>'.&mt('reCaptcha for helpdesk form set to version [_1]',$loginhash{'login'}{'recaptchaversion'}).
+                                           '</li>';
+                        }
+                    }
+                } elsif ($item eq 'captchathreshold') {
+                    if (ref($loginhash{'login'}) eq 'HASH') {
+                        if (($loginhash{'login'}{'captcha'} eq 'recaptcha') &&
+                            ($loginhash{'login'}{'recaptchaversion'} eq '3')) {
+                            $resulttext .= '<li>'.&mt('reCaptcha for helpdesk form rejection threshold set to [_1]',$loginhash{'login'}{'captchathreshold'}).
                                            '</li>';
                         }
                     }
@@ -18423,11 +18474,30 @@
                         if ($confighash{'passwords'}{'captcha'} eq 'original') {
                             $resulttext .= '<li>'.&mt('CAPTCHA validation set to use: original CAPTCHA').'</li>';
                         } elsif ($confighash{'passwords'}{'captcha'} eq 'recaptcha') {
-                            $resulttext .= '<li>'.&mt('CAPTCHA validation set to use: reCAPTCHA').' '.
-                                           &mt('version: [_1]',$confighash{'passwords'}{'recaptchaversion'}).'<br />';
+                            $resulttext .= '<li>'.&mt('CAPTCHA validation set to use: reCaptcha').' '.
+                                           &mt('version: [_1]',$confighash{'passwords'}{'recaptchaversion'});
+                            if ($confighash{'passwords'}{'recaptchaversion'} eq '3') {
+                                $resulttext .= ' '.&mt('rejection threshold set to [_1]',
+                                                       $confighash{'passwords'}{'captchathreshold'});
+                            }
+                            $resulttext .= '<br />';
                             if (ref($confighash{'passwords'}{'recaptchakeys'}) eq 'HASH') {
-                                $resulttext .= &mt('Public key: [_1]',$confighash{'passwords'}{'recaptchakeys'}{'public'}).'</br>'.
-                                               &mt('Private key: [_1]',$confighash{'passwords'}{'recaptchakeys'}{'private'}).'</li>';
+                                $resulttext .= &mt('Site key: [_1]',$confighash{'passwords'}{'recaptchakeys'}{'public'}).'</br>'.
+                                               &mt('Secret key: [_1]',$confighash{'passwords'}{'recaptchakeys'}{'private'}).'</li>';
+                            }
+                        } elsif (($confighash{'passwords'}{'captcha'} eq 'hcaptcha') ||
+                                 ($confighash{'passwords'}{'captcha'} eq 'turnstile')) {
+                            my %captchas = &captcha_phrases();
+                            if ($captchas{$confighash{'passwords'}{'captcha'}}) {
+                                $resulttext .= '<li>'.&mt('CAPTCHA validation set to use: [_1]',
+                                                          $captchas{$confighash{'passwords'}{'captcha'}}).
+                                               '<br />';
+                                if (ref($confighash{'passwords'}{'recaptchakeys'}) eq 'HASH') {
+                                    $resulttext .= &mt('Site key: [_1]',$confighash{'passwords'}{'recaptchakeys'}{'public'}).'</br>'.
+                                                   &mt('Secret key: [_1]',$confighash{'passwords'}{'recaptchakeys'}{'private'}).'</li>';
+                                }
+                            } else {
+                                $resulttext .= &mt('CAPTCHA validation set to use unknown type.');
                             }
                         } else {
                             $resulttext .= '<li>'.&mt('No CAPTCHA validation').'</li>';
@@ -19348,7 +19418,12 @@
 #
     $save_usercreate{'cancreate'}{'captcha'} = $savecaptcha{'captcha'};
     $save_usercreate{'cancreate'}{'recaptchakeys'} = $savecaptcha{'recaptchakeys'};
-    $save_usercreate{'cancreate'}{'recaptchaversion'} = $savecaptcha{'recaptchaversion'};
+    if ($savecaptcha{'captcha'} eq 'recaptcha') {
+        $save_usercreate{'cancreate'}{'recaptchaversion'} = $savecaptcha{'recaptchaversion'};
+        if ($savecaptcha{'recaptchaversion'} eq '3') {
+            $save_usercreate{'cancreate'}{'captchathreshold'} = $savecaptcha{'captchathreshold'};
+        }
+    }
     $save_usercreate{'cancreate'}{'selfcreate'} = $cancreate{'selfcreate'};
     if (ref($cancreate{'notify'}) eq 'HASH') {
         $save_usercreate{'cancreate'}{'notify'} = $cancreate{'notify'};
@@ -19619,21 +19694,25 @@
                             $pubkey = $savecaptcha{$type}{'public'};
                             $privkey = $savecaptcha{$type}{'private'};
                         }
-                        $chgtext .= &mt('ReCAPTCHA keys changes').'<ul>';
+                        $chgtext .= &mt('CAPTCHA keys changes').'<ul>';
                         if (!$pubkey) {
-                            $chgtext .= '<li>'.&mt('Public key deleted').'</li>';
+                            $chgtext .= '<li>'.&mt('Site key deleted').'</li>';
                         } else {
-                            $chgtext .= '<li>'.&mt('Public key set to [_1]',$pubkey).'</li>';
+                            $chgtext .= '<li>'.&mt('Site key set to [_1]',$pubkey).'</li>';
                         }
                         if (!$privkey) {
-                            $chgtext .= '<li>'.&mt('Private key deleted').'</li>';
+                            $chgtext .= '<li>'.&mt('Secret key deleted').'</li>';
                         } else {
-                            $chgtext .= '<li>'.&mt('Private key set to [_1]',$pubkey).'</li>';
+                            $chgtext .= '<li>'.&mt('Secret key set to [_1]',$pubkey).'</li>';
                         }
                         $chgtext .= '</ul>';
                     } elsif ($type eq 'recaptchaversion') {
                         if ($savecaptcha{'captcha'} eq 'recaptcha') {
-                            $chgtext .= &mt('ReCAPTCHA set to version [_1]',$savecaptcha{$type});
+                            $chgtext .= &mt('reCaptcha set to version [_1]',$savecaptcha{$type});
+                        }
+                    } elsif ($type eq 'captchathreshold') {
+                        if (($savecaptcha{'captcha'} eq 'recaptcha') && ($savecaptcha{'recaptchaversion'} eq '3')) {
+                            $chgtext .= &mt('reCaptcha v3 rejection threshold set to [_1]',$savecaptcha{$type});
                         }
                     } elsif ($type eq 'emailusername') {
                         if (ref($cancreate{'emailusername'}) eq 'HASH') {
@@ -19785,9 +19864,18 @@
     my ($container,$changes,$newsettings,$currsettings) = @_;
     return unless ((ref($changes) eq 'HASH') && (ref($newsettings) eq 'HASH'));
     $newsettings->{'captcha'} = $env{'form.'.$container.'_captcha'};
-    unless ($newsettings->{'captcha'} eq 'recaptcha' || $newsettings->{'captcha'} eq 'notused') {
+    unless (($newsettings->{'captcha'} eq 'successor') ||
+	    ($newsettings->{'captcha'} eq 'notused')) {
         $newsettings->{'captcha'} = 'original';
     }
+    if ($newsettings->{'captcha'} eq 'successor') {
+       $newsettings->{'captcha'} = $env{'form.'.$container.'_captchaprovider'};
+       unless (($newsettings->{'captcha'} eq 'recaptcha') ||
+               ($newsettings->{'captcha'} eq 'hcaptcha') ||
+               ($newsettings->{'captcha'} eq 'turnstile')) {
+           $newsettings->{'captcha'} = 'original';
+       }
+    }
     my %current;
     if (ref($currsettings) eq 'HASH') {
         %current = %{$currsettings};
@@ -19805,8 +19893,11 @@
             $changes->{'captcha'} = 1;
         }
     }
-    my ($newpub,$newpriv,$currpub,$currpriv,$newversion,$currversion);
-    if ($newsettings->{'captcha'} eq 'recaptcha') {
+    my ($newpub,$newpriv,$currpub,$currpriv,$newversion,$currversion,
+        $newthreshold,$currthreshold);
+    if (($newsettings->{'captcha'} eq 'recaptcha') ||
+        ($newsettings->{'captcha'} eq 'hcaptcha') ||
+        ($newsettings->{'captcha'} eq 'turnstile')) {
         $newpub = $env{'form.'.$container.'_recaptchapub'};
         $newpriv = $env{'form.'.$container.'_recaptchapriv'};
         $newpub =~ s/[^\w\-]//g;
@@ -19815,17 +19906,30 @@
                                              public  => $newpub,
                                              private => $newpriv,
                                           };
-        $newversion = $env{'form.'.$container.'_recaptchaversion'};
-        $newversion =~ s/\D//g;
-        if ($newversion ne '2') {
-            $newversion = 1;
+        if ($newsettings->{'captcha'} eq 'recaptcha') {
+            $newversion = $env{'form.'.$container.'_recaptchaversion'};
+            $newversion =~ s/\D//g;
+            if ($newversion eq '3') {
+                $newthreshold = $env{'form.'.$container.'_captchathreshold'};
+                $newthreshold =~ s/^\s+|\s+$//g;
+                unless (($newthreshold =~ /^\d(|\.\d*)$/) ||
+                        ($newthreshold =~ /^\.\d+$/) &&
+                        ($newthreshold >= 0.0) && ($newthreshold <= 1.0)) {
+                    $newthreshold = 0.5;
+                }
+                $newsettings->{'captchathreshold'} = $newthreshold;
+            } else {
+                $newversion = 2;
+            }
+            $newsettings->{'recaptchaversion'} = $newversion;
         }
-        $newsettings->{'recaptchaversion'} = $newversion;
     }
     if (ref($current{'recaptchakeys'}) eq 'HASH') {
         $currpub = $current{'recaptchakeys'}{'public'};
         $currpriv = $current{'recaptchakeys'}{'private'};
-        unless ($newsettings->{'captcha'} eq 'recaptcha') {
+        unless (($newsettings->{'captcha'} eq 'recaptcha') ||
+                ($newsettings->{'captcha'} eq 'hcaptcha') ||
+                ($newsettings->{'captcha'} eq 'turnstile')) {
             $newsettings->{'recaptchakeys'} = {
                                                  public  => '',
                                                  private => '',
@@ -19834,9 +19938,12 @@
     }
     if ($current{'captcha'} eq 'recaptcha') {
         $currversion = $current{'recaptchaversion'};
-        if ($currversion ne '2') {
+        if (($currversion ne '2') && ($currversion ne '3')) {
             $currversion = 1;
         }
+        if ($currversion eq '3') {
+            $currthreshold = $current{'captchathreshold'};
+        }
     }
     if ($currversion ne $newversion) {
         if ($container eq 'cancreate') {
@@ -19864,6 +19971,22 @@
             $changes->{'recaptchakeys'} = 1;
         }
     }
+    if (($newsettings->{'captcha'} eq 'recaptcha') && ($current{'captcha'} eq 'recaptcha') &&
+        ($newsettings->{'recaptchaversion'} eq '3') && ($currversion eq '3')) {
+        if ($newsettings->{'captchathreshold'} ne $currthreshold) {
+            if ($container eq 'cancreate') {
+                if (ref($changes->{'cancreate'}) eq 'ARRAY') {
+                    push(@{$changes->{'cancreate'}},'captchathreshold');
+                } elsif (!defined($changes->{'cancreate'})) {
+                    $changes->{'cancreate'} = ['captchathreshold'];
+                }
+            } elsif ($container eq 'passwords') {
+                $changes->{'reset'} = 1;
+            } else {
+                $changes->{'captchathreshold'} = 1;
+            }
+        }
+    }
     return;
 }
 
@@ -24010,64 +24133,92 @@
 <script type="text/javascript">
 // <![CDATA[
 
-function updateCaptcha(caller,context) {
-    var privitem;
-    var pubitem;
-    var privtext;
-    var pubtext;
-    var versionitem;
-    var versiontext;
-    if (document.getElementById(context+'_recaptchapub')) {
-        pubitem = document.getElementById(context+'_recaptchapub');
+function updateCaptcha(radio,context,form) {
+    var keysitem;
+    var typeitem;
+    var veritem;
+    if (document.getElementById(context+'_recaptchakeys')) {
+        keysitem = document.getElementById(context+'_recaptchakeys');
     } else {
         return;
     }
-    if (document.getElementById(context+'_recaptchapriv')) {
-        privitem = document.getElementById(context+'_recaptchapriv');
+    if (document.getElementById(context+'_recaptchatype')) {
+        typeitem = document.getElementById(context+'_recaptchatype');
     } else {
         return;
     }
-    if (document.getElementById(context+'_recaptchapubtxt')) {
-        pubtext = document.getElementById(context+'_recaptchapubtxt');
+    if (document.getElementById(context+'_recaptchaver')) {
+        veritem = document.getElementById(context+'_recaptchaver');
     } else {
         return;
     }
-    if (document.getElementById(context+'_recaptchaprivtxt')) {
-        privtext = document.getElementById(context+'_recaptchaprivtxt');
+    if (form.elements[context+'_'+radio].length) {
+        for (var i=0; i<form.elements[context+'_'+radio].length; i++) {
+            if (form.elements[context+'_'+radio][i].checked) {
+                if (form.elements[context+'_'+radio][i].value == 'successor') {
+                    keysitem.style.display='block';
+                    typeitem.style.display='block';
+                    updateCaptchaProv('captchaprovider',context,form);
+                } else {
+                    keysitem.style.display='none';
+                    typeitem.style.display='none';
+                    veritem.style.display='none';
+                }
+                break;
+            }
+        }
+    }
+    return;
+}
+
+function updateCaptchaProv(radio,context,form) {
+    var veritem;
+    if (document.getElementById(context+'_recaptchaver')) {
+        veritem = document.getElementById(context+'_recaptchaver');
     } else {
         return;
     }
-    if (document.getElementById(context+'_recaptchaversion')) {
-        versionitem = document.getElementById(context+'_recaptchaversion');
+    veritem.style.display = 'none';
+    if (form.elements[context+'_'+radio].length) {
+        for (var i=0; i<form.elements[context+'_'+radio].length; i++) {
+            if (form.elements[context+'_'+radio][i].checked) {
+                if (form.elements[context+'_'+radio][i].value == 'recaptcha') {
+                    veritem.style.display = 'block';
+                    updateRecapVer('recaptchaversion',context,form);
+                }
+            }
+            break;
+        }
+    }
+}
+
+function updateRecapVer(radio,context,form) {
+    var threshtxtitem;
+    if (document.getElementById(context+'_recaptchathrtxt')) {
+        threshtxtitem = document.getElementById(context+'_recaptchathrtxt');
     } else {
         return;
     }
-    if (document.getElementById(context+'_recaptchavertxt')) {
-        versiontext = document.getElementById(context+'_recaptchavertxt');
+    var thresholditem;
+    if (document.getElementById(context+'_recaptchathresh')) {
+        thresholditem = document.getElementById(context+'_recaptchathresh');
     } else {
         return;
     }
-    if (caller.checked) {
-        if (caller.value == 'recaptcha') {
-            pubitem.type = 'text';
-            privitem.type = 'text';
-            pubitem.size = '40';
-            privitem.size = '40';
-            pubtext.innerHTML = "$lt{'pub'}";
-            privtext.innerHTML = "$lt{'priv'}";
-            versionitem.type = 'text';
-            versionitem.size = '3';
-            versiontext.innerHTML = "$lt{'ver'}";
-        } else {
-            pubitem.type = 'hidden';
-            privitem.type = 'hidden';
-            versionitem.type = 'hidden';
-            pubtext.innerHTML = '';
-            privtext.innerHTML = '';
-            versiontext.innerHTML = '';
+    thresholditem.type = 'hidden';
+    threshtxtitem.innerHTML = "";
+    if (form.elements[context+'_'+radio].length) {
+        for (var i=0; i<form.elements[context+'_'+radio].length; i++) {
+            if (form.elements[context+'_'+radio][i].checked) {
+                if (form.elements[context+'_'+radio][i].value == '3') {
+                    thresholditem.type = 'text';
+                    thresholditem.size = '3';
+                    threshtxtitem.innerHTML = "$lt{'threshold'}";
+                }
+                break;
+            }
         }
     }
-    return;
 }
 
 // ]]>
@@ -24131,12 +24282,19 @@
 
 sub captcha_phrases {
     return &Apache::lonlocal::texthash (
-                 priv => 'Private key',
-                 pub  => 'Public key',
-                 original  => 'original (CAPTCHA)',
-                 recaptcha => 'successor (ReCAPTCHA)',
+                 captcha   => 'Captcha validation',
+                 keys      => 'Keys',
+                 priv      => 'Secret',
+                 pub       => 'Site',
+                 original  => 'original (perl module)',
+                 successor => 'third party',
                  notused   => 'unused',
-                 ver => 'ReCAPTCHA version (1 or 2)',
+                 type      => 'Provider',
+                 recaptcha => 'reCaptcha',
+                 hcaptcha  => 'hCaptcha',
+                 turnstile => 'Turnstile',
+                 ver       => 'reCaptcha version (2 or 3)',
+                 threshold => '-- reject if score below:',
     );
 }
 
Index: loncom/interface/createaccount.pm
diff -u loncom/interface/createaccount.pm:1.90 loncom/interface/createaccount.pm:1.91
--- loncom/interface/createaccount.pm:1.90	Tue Feb 18 17:45:11 2025
+++ loncom/interface/createaccount.pm	Tue Dec  9 02:29:03 2025
@@ -4,7 +4,7 @@
 # kerberos, or SSO) or an e-mail address. Requests to use an e-mail address as
 # username may be processed automatically, or may be queued for approval.
 #
-# $Id: createaccount.pm,v 1.90 2025/02/18 17:45:11 raeburn Exp $
+# $Id: createaccount.pm,v 1.91 2025/12/09 02:29:03 raeburn Exp $
 #
 # Copyright Michigan State University Board of Trustees
 #
@@ -335,6 +335,7 @@
     } elsif (!$token) {
         &print_header($r,$start_page,$courseid,$pagetitle);
         my $now=time;
+        my $formname = 'createaccount';
         if ((grep(/^login$/,@{$cancreate})) &&
             ((!grep(/^email$/,@{$cancreate})) || ($need_affiliation))) {
             if (open(my $jsh,"<","$include/londes.js")) {
@@ -342,7 +343,8 @@
                     $r->print($line);
                 }
                 close($jsh);
-                $r->print(&javascript_setforms($now));
+                my $submitform = "               document.$formname.submit();\n";
+                $r->print(&javascript_setforms($now,$submitform));
             }
         }
         if (grep(/^email$/,@{$cancreate})) {
@@ -350,7 +352,7 @@
         }
         my $usertype = &get_usertype($domain);
         $output = &print_username_form($r,$domain,$domdesc,$cancreate,$now,$lonhost,
-                                       $include,$courseid,$emailusername,
+                                       $formname,$include,$courseid,$emailusername,
                                        $statusforemail,$usernameset,$condition,
                                        $excluded,$usertype,$types,$usertypes,$othertitle);
     }
@@ -391,6 +393,7 @@
                       'token','serverid','uname','upass','phase','create_with_email',
                       'code','crypt','cfirstname','clastname','g-recaptcha-response',
                       'recaptcha_challenge_field','recaptcha_response_field',
+                      'h-captcha-response','cf-turnstile-response',
                       'cmiddlename','cgeneration','cpermanentemail','cid']).
                   '</form>');
     }
@@ -436,7 +439,8 @@
 }
 
 sub javascript_setforms {
-    my ($now,$emailusername,$captcha,$usertype,$recaptchaversion,$usernameset,$condition,$excluded) =  @_;
+    my ($now,$submitform,$emailusername,$captcha,$usertype,
+        $recaptcha_version,$pubkey,$usernameset,$condition,$excluded) =  @_;
     my ($setuserinfo, at required,$requiredchk);
     if (ref($emailusername) eq 'HASH') {
         if (ref($emailusername->{$usertype}) eq 'HASH') {  
@@ -454,7 +458,7 @@
             $setuserinfo .= '                    server.elements.code.value=client.elements.code.value;'."\n".
                             '                    server.elements.crypt.value=client.elements.crypt.value;'."\n";
         } elsif ($captcha eq 'recaptcha') {
-            if ($recaptchaversion ne '2') {
+            if ($recaptcha_version < 2) {
                 $setuserinfo .=
                 '                    server.elements.recaptcha_challenge_field.value=client.elements.recaptcha_challenge_field.value;'."\n".
                 '                    server.elements.recaptcha_response_field.value=client.elements.recaptcha_response_field.value;'."\n";
@@ -515,7 +519,7 @@
 $setuserinfo
                     client.elements.upasscheck$now.value='';
                 }
-                server.submit();
+$submitform
             }
         }
         return false;
@@ -524,8 +528,8 @@
 // ]]>
 </script>
 ENDSCRIPT
-    if (($captcha eq 'recaptcha') && ($recaptchaversion eq '2')) {
-        $js .= "\n".'<script src="https://www.google.com/recaptcha/api.js"></script>'."\n";
+    if (($captcha eq 'recaptcha') || ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
+        $js .= "\n".&Apache::loncommon::captcha_js($captcha,$recaptcha_version,$pubkey)."\n";
     }
     return $js;
 }
@@ -606,9 +610,9 @@
 }
 
 sub print_username_form {
-    my ($r,$domain,$domdesc,$cancreate,$now,$lonhost,$include,$courseid,$emailusername,
-        $statusforemail,$usernameset,$condition,$excluded,$usertype,$types,$usertypes,
-        $othertitle) = @_;
+    my ($r,$domain,$domdesc,$cancreate,$now,$lonhost,$formname,$include,
+        $courseid,$emailusername,$statusforemail,$usernameset,$condition,
+        $excluded,$usertype,$types,$usertypes,$othertitle) = @_;
     my %lt = &Apache::lonlocal::texthash (
                                          crac => 'Create account with a username provided by this institution',
                                          clca => 'Create LON-CAPA account',
@@ -677,8 +681,9 @@
                 $output .= "\n".'<p><input type="submit" name="reportedtype" value="'.&mt('Submit').'" />'.
                            '</p></fieldset></form>'."\n";
             } else {
-                my ($captchaform,$error,$captcha,$recaptchaversion) = 
-                    &Apache::loncommon::captcha_display('usercreation',$lonhost);
+                my ($captchaform,$error,$captcha,$recaptcha_version,$pubkey,
+                    $hdn_captcha,$recaptcha_submit,$recaptcha_badge) =
+                    &Apache::loncommon::captcha_display('usercreation',$lonhost,$formname);
                 if ($error) {
                     my $helpdesk = '/adm/helpdesk?origurl=%2fadm%2fcreateaccount';
                     if ($courseid ne '') {
@@ -722,8 +727,9 @@
                         }
                     }
                     $output .= &print_dataentry_form($r,$domain,$lonhost,$include,$now,$captchaform,
-                                                     $courseid,$emailusername,$captcha,$usertype,
-                                                     $recaptchaversion,$usernameset,$condition,$excluded);
+                                                     $courseid,$emailusername,$captcha,$formname,$pubkey,
+                                                     $hdn_captcha,$recaptcha_submit,$recaptcha_badge,$usertype,
+                                                     $recaptcha_version,$usernameset,$condition,$excluded);
                 }
             }
             $output .= '</div>';
@@ -851,10 +857,17 @@
                                          $contact_name,$contact_email);
                 return $output;
             } else {
-                my ($captcha_chk,$captcha_error) = &Apache::loncommon::captcha_response('usercreation',$server);
+                my ($captcha_chk,$captcha_error,$captcha) = &Apache::loncommon::captcha_response('usercreation',$server);
                 if ($captcha_chk != 1) {
-                    $output = '<span class="LC_warning">'.
-                              &mt('Validation of the code you entered failed.').'</span>'.
+                    my $errormsg;
+                    if ($captcha eq 'original') {
+                        $errormsg = &mt('Validation of the code you entered failed');
+                    } elsif (($captcha eq 'recaptcha') ||
+                             ($captcha eq 'hcaptcha') ||
+                             ($captcha eq 'turnstile')) {
+                        $errormsg = &mt('Validation of human, not robot, failed');
+                    }
+                    $output = '<span class="LC_warning">'.$errormsg.'</span>'."\n".
                               '<br />'.$captcha_error."\n".'<br /><p>'.
                                &mt('[_1]Return[_2] to the previous page to try again.',
                                    '<a href="javascript:document.retryemail.submit();">','</a>')."\n".
@@ -1179,15 +1192,20 @@
 #
 sub print_dataentry_form {
     my ($r,$domain,$lonhost,$include,$now,$captchaform,$courseid,$emailusername,$captcha,
-        $usertype,$recaptchaversion,$usernameset,$condition,$excluded) = @_;
+        $formname,$pubkey,$hdn_captcha,$recaptcha_submit,$recaptcha_badge,$usertype,
+        $recaptcha_version,$usernameset,$condition,$excluded) = @_;
     my ($error,$output);
     if (open(my $jsh,"<","$include/londes.js")) {
         while(my $line = <$jsh>) {
             $r->print($line);
         }
         close($jsh);
-        $output = &javascript_setforms($now,$emailusername,$captcha,$usertype,$recaptchaversion,
-                                       $usernameset,$condition,$excluded).
+        my $submitform = "               document.$formname.submit();\n";
+        if (($captcha eq 'recaptcha') && ($recaptcha_version eq '3') && ($recaptcha_submit ne '')) {
+            $submitform = $recaptcha_submit;
+        }
+        $output = &javascript_setforms($now,$submitform,$emailusername,$captcha,$usertype,
+                                       $recaptcha_version,$pubkey,$usernameset,$condition,$excluded).
                   "\n".&javascript_checkpass($now,'email',$domain);
         my ($lkey,$ukey) = &Apache::loncommon::des_keys();
         my ($lextkey,$uextkey) = &getkeys($lkey,$ukey);
@@ -1195,7 +1213,7 @@
                                            $lonhost);
         my $showsubmit = 1;
         my $serverform =
-            '<form name="createaccount" method="post" target="_top" action="/adm/createaccount">';
+            '<form name="'.$formname.'" method="post" target="_top" action="/adm/createaccount">';
         if ($courseid ne '') {
             $serverform .= '<input type="hidden" name="courseid" value="'.$courseid.'"/>'."\n";
         }
@@ -1211,27 +1229,28 @@
    <input type="hidden" name="crypt" value="" />
    <input type="hidden" name="code" value="" />
 ';
-        } elsif ($captcha eq 'recaptcha') {
-            if ($recaptchaversion eq '2') {
-                $serverform .= &Apache::lonhtmlcommon::start_pick_box().
-                               &Apache::lonhtmlcommon::row_title(&mt('Validation').'<b>*</b>',
-                                                                 'LC_pick_box_title',
-                                                                 'LC_oddrow_value')."\n".
-                                                                 $captchaform.
-                               &Apache::lonhtmlcommon::row_closure(1)."\n".
-                               &Apache::lonhtmlcommon::row_title()."\n".
-                               '<br /><input type="button" name="createaccount" value="'.
-                               &mt('Create account').'" onclick="checkpass('."'createaccount','newemail'".')" />'.
-                               &Apache::lonhtmlcommon::row_closure(1)."\n".
-                               &Apache::lonhtmlcommon::end_pick_box();
-                undef($captchaform);
-                undef($showsubmit);
-            } else {
-                $serverform .= '
+        } elsif ((($captcha eq 'recaptcha') && ($recaptcha_version eq '2')) ||
+                 ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
+            $serverform .= &Apache::lonhtmlcommon::start_pick_box().
+                           &Apache::lonhtmlcommon::row_title(&mt('Validation').'<b>*</b>',
+                                                             'LC_pick_box_title',
+                                                             'LC_oddrow_value')."\n".
+                                                             $captchaform.
+                           &Apache::lonhtmlcommon::row_closure(1)."\n".
+                           &Apache::lonhtmlcommon::row_title()."\n".
+                           '<br /><input type="button" name="createaccount" value="'.
+                           &mt('Create account').'" onclick="checkpass('."'createaccount','newemail'".')" />'.
+                           &Apache::lonhtmlcommon::row_closure(1)."\n".
+                           &Apache::lonhtmlcommon::end_pick_box();
+            undef($captchaform);
+            undef($showsubmit);
+        } elsif (($captcha eq 'recaptcha') && ($recaptcha_version eq '3')) {
+            $serverform .= "\n$hdn_captcha\n";
+        } elsif (($captcha eq 'recaptcha') && ($recaptcha_version < 2)) {
+            $serverform .= '
    <input type="hidden" name="recaptcha_challenge_field" value="" />
    <input type="hidden" name="recaptcha_response_field" value="" />
 ';
-            }
         }
         if ($usertype ne '') {
             $serverform .= '<input type="hidden" name="type" value="'.
@@ -1279,6 +1298,9 @@
                    '<p class="LC_info">'.
                    &mt('Fields marked [_1]*[_2] are required.','<b>','</b>').
                    '</p>';
+        if ($recaptcha_badge) {
+            $output .= '<p class="LC_info">'.$recaptcha_badge.'</p>';
+        }
     } else {
         $output = &mt('Could not load javascript file [_1]','<tt>londes.js</tt>');
     }
Index: loncom/interface/loncommon.pm
diff -u loncom/interface/loncommon.pm:1.1486 loncom/interface/loncommon.pm:1.1487
--- loncom/interface/loncommon.pm:1.1486	Thu Nov 27 21:19:07 2025
+++ loncom/interface/loncommon.pm	Tue Dec  9 02:29:03 2025
@@ -1,7 +1,7 @@
 # The LearningOnline Network with CAPA
 # a pile of common routines
 #
-# $Id: loncommon.pm,v 1.1486 2025/11/27 21:19:07 raeburn Exp $
+# $Id: loncommon.pm,v 1.1487 2025/12/09 02:29:03 raeburn Exp $
 #
 # Copyright Michigan State University Board of Trustees
 #
@@ -8944,6 +8944,11 @@
   font-weight: normal;
 }
 
+fieldset.LC_captcha > legend {
+  font-weight: normal;
+  font-style: italic;
+}
+
 p.LC_medium_line {
   line-height: 0.85em;
 }
@@ -9814,6 +9819,7 @@
   padding-bottom:5px;
 }
 
+.grecaptcha-badge { visibility: hidden; }
 
 END
 }
@@ -19893,8 +19899,8 @@
 }
 
 sub captcha_display {
-    my ($context,$lonhost,$defdom) = @_;
-    my ($output,$error);
+    my ($context,$lonhost,$formname,$defdom) = @_;
+    my ($output,$error,$hdn_captcha,$recaptcha_submit,$recaptcha_badge);
     my ($captcha,$pubkey,$privkey,$version) = 
         &get_captcha_config($context,$lonhost,$defdom);
     if ($captcha eq 'original') {
@@ -19902,32 +19908,58 @@
         unless ($output) {
             $error = 'captcha';
         }
-    } elsif ($captcha eq 'recaptcha') {
-        $output = &create_recaptcha($pubkey,$version);
-        unless ($output) {
-            $error = 'recaptcha';
+    } else {
+        if ($captcha eq 'recaptcha') {
+            my $recaptcha = &create_recaptcha($pubkey,$version);
+            if ($version eq '3') {
+                $hdn_captcha = $recaptcha;
+                $recaptcha_submit = <<ENDJS;
+    grecaptcha.ready(function() {
+      grecaptcha.execute('$pubkey', {action: 'submit'}).then(function(token) {
+        document.getElementById('g-recaptcha-response').value = token;
+        document.$formname.submit();
+      });
+    });
+ENDJS
+                $recaptcha_badge =
+                    &mt('This site is protected by reCAPTCHA and the Google [_1]Privacy Policy[_2] and [_3]Terms of Service[_4] apply.',
+                        '<a href="https://policies.google.com/privacy">',
+                        '</a>',
+                        '<a href="https://policies.google.com/terms">',
+                        '</a>');
+            } else {
+                $output = $recaptcha;
+            }
+        } elsif ($captcha eq 'hcaptcha') {
+            $output = &create_hcaptcha($pubkey);
+        } elsif ($captcha eq 'turnstile') {
+            $output = &create_turnstile($pubkey);
+        }
+        unless ($output || $hdn_captcha) {
+            $error = $captcha;
         }
     }
-    return ($output,$error,$captcha,$version);
+    return ($output,$error,$captcha,$version,$pubkey,$hdn_captcha,$recaptcha_submit,$recaptcha_badge);
 }
 
 sub captcha_response {
     my ($context,$lonhost,$defdom) = @_;
     my ($captcha_chk,$captcha_error);
-    my ($captcha,$pubkey,$privkey,$version) = &get_captcha_config($context,$lonhost,$defdom);
+    my ($captcha,$pubkey,$privkey,$version,$threshold) =
+        &get_captcha_config($context,$lonhost,$defdom);
     if ($captcha eq 'original') {
         ($captcha_chk,$captcha_error) = &check_captcha();
-    } elsif ($captcha eq 'recaptcha') {
-        $captcha_chk = &check_recaptcha($privkey,$version);
+    } elsif (($captcha eq 'recaptcha') || ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
+        $captcha_chk = &check_recaptcha($captcha,$privkey,$pubkey,$version,$threshold);
     } else {
         $captcha_chk = 1;
     }
-    return ($captcha_chk,$captcha_error);
+    return ($captcha_chk,$captcha_error,$captcha);
 }
 
 sub get_captcha_config {
     my ($context,$lonhost,$dom_in_effect) = @_;
-    my ($captcha,$pubkey,$privkey,$version,$hashtocheck);
+    my ($captcha,$pubkey,$privkey,$version,$threshold,$hashtocheck);
     my $hostname = &Apache::lonnet::hostname($lonhost);
     my $serverhomeID = &Apache::lonnet::get_server_homeID($hostname);
     my $serverhomedom = &Apache::lonnet::host_domain($serverhomeID);
@@ -19936,16 +19968,18 @@
         if (ref($domconfig{$context}) eq 'HASH') {
             $hashtocheck = $domconfig{$context}{'cancreate'};
             if (ref($hashtocheck) eq 'HASH') {
-                if ($hashtocheck->{'captcha'} eq 'recaptcha') {
+                $captcha = $hashtocheck->{'captcha'};
+                if (($captcha eq 'recaptcha') || ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
                     if (ref($hashtocheck->{'recaptchakeys'}) eq 'HASH') {
                         $pubkey = $hashtocheck->{'recaptchakeys'}{'public'};
                         $privkey = $hashtocheck->{'recaptchakeys'}{'private'};
                     }
                     if ($privkey && $pubkey) {
-                        $captcha = 'recaptcha';
-                        $version = $hashtocheck->{'recaptchaversion'};
-                        if ($version ne '2') {
-                            $version = 1;
+                        if ($captcha eq 'recaptcha') {
+                            $version = $hashtocheck->{'recaptchaversion'};
+                            if ($version eq '3') {
+                                $threshold = $hashtocheck->{'captchathreshold'};
+                            }
                         }
                     } else {
                         $captcha = 'original';
@@ -19959,14 +19993,18 @@
         }
     } elsif ($context eq 'login') {
         my %domconfhash = &Apache::loncommon::get_domainconf($serverhomedom);
-        if ($domconfhash{$serverhomedom.'.login.captcha'} eq 'recaptcha') {
+        $captcha = $domconfhash{$serverhomedom.'.login.captcha'};
+        if (($captcha eq 'recaptcha') || ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
             $pubkey = $domconfhash{$serverhomedom.'.login.recaptchakeys_public'};
             $privkey = $domconfhash{$serverhomedom.'.login.recaptchakeys_private'};
             if ($privkey && $pubkey) {
-                $captcha = 'recaptcha';
-                $version = $domconfhash{$serverhomedom.'.login.recaptchaversion'};
-                if ($version ne '2') {
-                    $version = 1; 
+                if ($captcha eq 'recaptcha') {
+                    $version = $domconfhash{$serverhomedom.'.login.recaptchaversion'};
+                    if ($version eq '3') {
+                        $threshold = $domconfhash{$serverhomedom.'.login.captchathreshold'};
+                    } elsif ($version ne '2') {
+                        $version = 1;
+                    }
                 }
             } else {
                 $captcha = 'original';
@@ -19977,16 +20015,20 @@
     } elsif ($context eq 'passwords') {
         if ($dom_in_effect) {
             my %passwdconf = &Apache::lonnet::get_passwdconf($dom_in_effect);
-            if ($passwdconf{'captcha'} eq 'recaptcha') {
+            $captcha = $passwdconf{'captcha'};
+            if (($captcha eq 'recaptcha') || ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
                 if (ref($passwdconf{'recaptchakeys'}) eq 'HASH') {
                     $pubkey = $passwdconf{'recaptchakeys'}{'public'};
                     $privkey = $passwdconf{'recaptchakeys'}{'private'};
                 }
                 if ($privkey && $pubkey) {
-                    $captcha = 'recaptcha';
-                    $version = $passwdconf{'recaptchaversion'};
-                    if ($version ne '2') {
-                        $version = 1;
+                    if ($captcha eq 'recaptcha') {
+                        $version = $passwdconf{'recaptchaversion'};
+                        if ($version eq '3') {
+                            $threshold = $passwdconf{'captchathreshold'};
+                        } elsif ($version ne '2') {
+                            $version = 1;
+                        }
                     }
                 } else {
                     $captcha = 'original';
@@ -19995,8 +20037,8 @@
                 $captcha = 'original';
             }
         }
-    } 
-    return ($captcha,$pubkey,$privkey,$version);
+    }
+    return ($captcha,$pubkey,$privkey,$version,$threshold);
 }
 
 sub create_captcha {
@@ -20058,9 +20100,27 @@
     return ($captcha_chk,$captcha_error);
 }
 
+sub captcha_js {
+    my ($captcha,$version,$pubkey) = @_;
+    if ($captcha eq 'recaptcha') {
+        if ($version eq '2') {
+            return '<script async type="text/javascript" src="https://www.recaptcha.net/recaptcha/api.js"></script>';
+        } elsif ($version eq '3') {
+            return '<script async type="text/javascript" src="https://www.recaptcha.net/recaptcha/api.js?render='.$pubkey.'"></script>';
+        }
+    } elsif ($captcha eq 'hcaptcha') {
+        return '<script async  type="text/javascript" src="https://js.hcaptcha.com/1/api.js"></script>';
+    } elsif ($captcha eq 'turnstile') {
+        return '<script async type="text/javascript" src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>'."\n";
+    }
+    return;
+}
+
 sub create_recaptcha {
     my ($pubkey,$version) = @_;
-    if ($version >= 2) {
+    if ($version eq '3') {
+        return '<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" />';
+    } elsif ($version eq '2') {
         return '<div class="g-recaptcha" data-sitekey="'.$pubkey.'"></div>'.
                '<div style="padding:0;clear:both;margin:0;border:0"></div>';
     } else {
@@ -20077,33 +20137,63 @@
     }
 }
 
+sub create_hcaptcha {
+    my ($pubkey) = @_;
+    return '<div class="h-captcha" data-sitekey="'.$pubkey.'"></div>'.
+           '<div style="padding:0;clear:both;margin:0;border:0"></div>';
+}
+
+sub create_turnstile {
+    my ($pubkey) = @_;
+    return '<div class="cf-turnstile" data-sitekey="'.$pubkey.'"></div>'.
+           '<div style="padding:0;clear:both;margin:0;border:0"></div>';
+}
+
 sub check_recaptcha {
-    my ($privkey,$version) = @_;
+    my ($captcha,$privkey,$pubkey,$version,$threshold) = @_;
     my $captcha_chk;
     my $ip = &Apache::lonnet::get_requestor_ip();
-    if ($version >= 2) {
+    if (($captcha eq 'hcaptcha') || ($captcha eq 'turnstile') ||
+        (($captcha eq 'recaptcha') && ($version >= 2))) {
+        my $url;
         my %info = (
-                     secret   => $privkey, 
-                     response => $env{'form.g-recaptcha-response'},
+                     secret   => $privkey,
                      remoteip => $ip,
                    );
-        my $request=new HTTP::Request('POST','https://www.google.com/recaptcha/api/siteverify');
-        $request->content(join('&',map {
-                         my $name = escape($_);
-                         "$name=" . ( ref($info{$_}) eq 'ARRAY'
-                         ? join("&$name=", map {escape($_) } @{$info{$_}})
-                         : &escape($info{$_}) );
-        } keys(%info)));
+        if ($captcha eq 'recaptcha') {
+            $info{'response'} = $env{'form.g-recaptcha-response'};
+            $url = 'https://www.recaptcha.net/recaptcha/api/siteverify';
+        } elsif ($captcha eq 'hcaptcha') {
+            $info{'response'} = $env{'form.h-captcha-response'};
+            $info{'sitekey'} = $pubkey;
+            $url = 'https://api.hcaptcha.com/siteverify';
+        } elsif ($captcha eq 'turnstile') {
+            $info{'response'} = $env{'form.cf-turnstile-response'};
+            $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
+        }
+        my $data = join('&',map {
+                         my $name = &escape($_);
+                         "$name=" . &escape($info{$_});
+                        } keys(%info));
+        my $header = ['Content-Type' => 'application/x-www-form-urlencoded'];
+        my $request = new HTTP::Request('POST',$url,$header,$data);
         my $response = &LONCAPA::LWPReq::makerequest('',$request,'','',10,1);
         if ($response->is_success)  {
             my $data = JSON::DWIW->from_json($response->decoded_content);
             if (ref($data) eq 'HASH') {
                 if ($data->{'success'}) {
-                    $captcha_chk = 1;
+                    if (($captcha eq 'recaptcha') && ($version eq '3')) {
+                        my $score = $data->{'score'};
+                        if ($score >= $threshold) {
+                            $captcha_chk = 1;
+                        }
+                    } else {
+                        $captcha_chk = 1;
+                    }
                 }
             }
         }
-    } else {
+    } elsif ($captcha eq 'recaptcha') {
         my $captcha = Captcha::reCAPTCHA->new;
         my $captcha_result =
             $captcha->check_answer(
Index: loncom/interface/lonconfigsettings.pm
diff -u loncom/interface/lonconfigsettings.pm:1.75 loncom/interface/lonconfigsettings.pm:1.76
--- loncom/interface/lonconfigsettings.pm:1.75	Sat Jun 14 02:50:25 2025
+++ loncom/interface/lonconfigsettings.pm	Tue Dec  9 02:29:03 2025
@@ -1,7 +1,7 @@
 # The LearningOnline Network with CAPA
 # Handler to set domain-wide configuration settings
 #
-# $Id: lonconfigsettings.pm,v 1.75 2025/06/14 02:50:25 raeburn Exp $
+# $Id: lonconfigsettings.pm,v 1.76 2025/12/09 02:29:03 raeburn Exp $
 #
 # Copyright Michigan State University Board of Trustees
 #
@@ -311,6 +311,13 @@
                 foreach my $server (sort(keys(%domservers))) {
                     $onload .= "toggleSamlOptions(document.display,'$server');";
                 }
+                $onload .= "updateCaptcha('captcha','login',document.display);";
+            }
+            if (grep(/^passwords$/, at actions)) {
+                $onload .= "updateCaptcha('captcha','passwords',document.display);";
+            }
+            if (grep(/^cancreate$/, at actions)) {
+                $onload .= "updateCaptcha('captcha','cancreate',document.display);";
             }
             if ($onload) {
                 my %loaditems = (
Index: loncom/interface/lonsupportreq.pm
diff -u loncom/interface/lonsupportreq.pm:1.108 loncom/interface/lonsupportreq.pm:1.109
--- loncom/interface/lonsupportreq.pm:1.108	Tue Feb 25 05:35:26 2025
+++ loncom/interface/lonsupportreq.pm	Tue Dec  9 02:29:03 2025
@@ -1,7 +1,7 @@
 # The LearningOnline Network with CAPA
 # Helpdesk request form
 #
-# $Id: lonsupportreq.pm,v 1.108 2025/02/25 05:35:26 raeburn Exp $
+# $Id: lonsupportreq.pm,v 1.109 2025/12/09 02:29:03 raeburn Exp $
 #
 # Copyright Michigan State University Board of Trustees
 #
@@ -87,12 +87,15 @@
     my ($os,$browser,$bversion,$uname,$udom,$uhome,$urole,$usec,$email,$cid,
         $cdom,$cnum,$ctitle,$ccode,$sectionlist,$lastname,$firstname,$server,
         $formname,$public,$homeserver,$knownuser,$captcha_form,$captcha_error,
-        $captcha,$recaptcha_version,$extra_validations,%groupid);
+        $captcha,$recaptcha_version,$pubkey,$hdn_captcha,$recaptcha_badge,
+        $recaptcha_submit,$extra_validations,$submitform,%groupid);
     $function = &Apache::loncommon::get_users_function() if (!$function);
     $ccode = '';
     $os = $env{'browser.os'};
     $browser = $env{'browser.type'};
     $bversion = $env{'browser.version'};
+    $submitform = 'document.logproblem.submit();';
+    $formname = 'logproblem';
     if (($env{'user.name'} eq 'public') && ($env{'user.domain'} eq 'public')) {
         $public = 1;
     } else {
@@ -112,8 +115,13 @@
         $knownuser = 1;
     } else {
         my $lonhost = $r->dir_config('lonHostID');
-        ($captcha_form,$captcha_error,$captcha,$recaptcha_version) =
-            &Apache::loncommon::captcha_display('login',$lonhost);
+        ($captcha_form,$captcha_error,$captcha,$recaptcha_version,
+         $pubkey,$hdn_captcha,$recaptcha_submit,$recaptcha_badge) =
+            &Apache::loncommon::captcha_display('login',$lonhost,$formname);
+        if (($captcha eq 'recaptcha') && ($recaptcha_version eq '3') &&
+            ($recaptcha_submit ne '')) {
+            $submitform = $recaptcha_submit;
+        }
     }
     &Apache::loncommon::get_unprocessed_cgi($ENV{'QUERY_STRING'},['codedom',
                                                  'useremail','useraccount']);
@@ -140,7 +148,6 @@
         }
     }
 
-    $formname = 'logproblem';
     my $codedom = &get_domain();
     my %helpform;
     my %domconfig =
@@ -273,7 +280,7 @@
         return;
     }
     $extra_validations
-    document.logproblem.submit();
+    $submitform
 }
 
 END
@@ -322,8 +329,10 @@
 ENDJS
     if ($knownuser) {
         $js .="\n".'<script type="text/javascript" src="/res/adm/includes/file_upload.js"></script>';
-    } elsif ($recaptcha_version >=2) {
-        $js.= "\n".'<script src="https://www.google.com/recaptcha/api.js"></script>'."\n";
+    } else {
+        if (($captcha eq 'recaptcha') || ($captcha eq 'hcaptcha') || ($captcha eq 'turnstile')) {
+            $js .= "\n".&Apache::loncommon::captcha_js($captcha,$recaptcha_version,$pubkey)."\n";
+        }
     }
     my %add_entries = (
                        style    => "margin-top:0px;margin-bottom:0px;",
@@ -646,6 +655,7 @@
                </div>
                <div class="LC_floatleft" style="padding-top:0, padding-left:8px; padding-right:8px; padding-bottom:0; margin:0">
                 <input type="reset" value="$html_lt{'clfm'}" />
+                $hdn_captcha
                </div>
              </div>
              <div style="padding:0;clear:both;margin:0;border:0"></div>
@@ -655,7 +665,7 @@
     $r->print(<<END);
 $output
 </form>
-<br />
+$recaptcha_badge<br />
 </div>
 END
     $r->print(&Apache::loncommon::end_page());
@@ -671,7 +681,7 @@
     }
     my $lonhost = $r->dir_config('lonHostID');
     unless (($env{'user.name'} =~ /^$match_username$/) && (!$public)) {
-        my ($captcha_chk,$captcha_error) = 
+        my ($captcha_chk,$captcha_error,$captcha) = 
             &Apache::loncommon::captcha_response('login',$lonhost);
         if ($captcha_chk != 1) {
             $args = {
@@ -690,11 +700,18 @@
             if ($r->uri eq '/adm/helpdesk') {
                 &print_header($r,$url,'process');
             }
+            my $errormsg;
+            if ($captcha eq 'original') {
+                $errormsg = &mt('Validation of the code you entered failed');
+            } elsif (($captcha eq 'recaptcha') ||
+                     ($captcha eq 'hcaptcha') ||
+                     ($captcha eq 'turnstile')) {
+                $errormsg = &mt('Validation of human, not robot, failed');
+            }
             $r->print(
                 '<div class="LC_landmark" role="main">'.
                 '<h2 class="LC_heading_2">'.&mt('Support request failed').'</h2>'.
-                      &Apache::lonhtmlcommon::confirm_success(
-                        &mt('Validation of the code you entered failed.'),1).
+                      &Apache::lonhtmlcommon::confirm_success($errormsg,1).
                 '<br /><br />'.
                 &Apache::lonhtmlcommon::actionbox([
                     &mt('[_1]Go back[_2] and try again',
@@ -1239,7 +1256,7 @@
                                            back     => 'Back to last location',
                                            headline => 'help/support',
                                            stud     => 'Students',
-                                           ifyo     => 'If your problem is still unresolved, the form below can be used to send a question to the LON-CAPA helpdesk.',
+                                           ifyo     => 'If your problem is still unresolved, use this form to send a question to the LON-CAPA helpdesk.',
                                            cont     => 'Contact your instructor instead.',
                                          );
     my ($getstartlink,$reviewtext);
Index: loncom/interface/resetpw.pm
diff -u loncom/interface/resetpw.pm:1.54 loncom/interface/resetpw.pm:1.55
--- loncom/interface/resetpw.pm:1.54	Sat Feb 15 03:43:36 2025
+++ loncom/interface/resetpw.pm	Tue Dec  9 02:29:03 2025
@@ -1,7 +1,7 @@
 # The LearningOnline Network
 # Allow access to password changing via a token sent to user's e-mail. 
 #
-# $Id: resetpw.pm,v 1.54 2025/02/15 03:43:36 raeburn Exp $
+# $Id: resetpw.pm,v 1.55 2025/12/09 02:29:03 raeburn Exp $
 #
 # Copyright Michigan State University Board of Trustees
 #
@@ -115,8 +115,16 @@
         }
     }
     my %passwdconf = &Apache::lonnet::get_passwdconf($dom_in_effect);
-    my $clientip = &Apache::lonnet::get_requestor_ip($r);
+    my $formname = 'forgotpw';
     my $token = $env{'form.token'};
+    my ($captcha_form,$captcha_error,$captcha,$recaptcha_version,
+        $pubkey,$hdn_captcha,$recaptcha_submit,$recaptcha_badge);
+    unless (($passwdconf{'captcha'} eq 'notused') || ($token)) {
+        ($captcha_form,$captcha_error,$captcha,$recaptcha_version,
+         $pubkey,$hdn_captcha,$recaptcha_submit,$recaptcha_badge) =
+        &Apache::loncommon::captcha_display('passwords',$server,$formname,$dom_in_effect);
+    }
+    my $clientip = &Apache::lonnet::get_requestor_ip($r);
     my $useremail = $env{'form.useremail'};
     if (($udom ne '') && (!$otherinst) && (!$token)) {
         if ($uname ne '') {
@@ -256,6 +264,10 @@
 }
 
 END
+        my $submitform = "    document.$formname.submit();";
+        if ($recaptcha_submit ne '') {
+            $submitform = $recaptcha_submit;
+        }
         if ($passwdconf{resetprelink} eq 'either') {
             $js .= <<"END";
 function validInfo() {
@@ -264,7 +276,8 @@
         alert("$js_lt{'eith'}");
         return false;
     }
-    return true;
+$submitform
+    return false;
 }
 END
         } else {
@@ -279,14 +292,20 @@
         alert("$js_lt{'mail'}");
         return false;
     }
-    return true;
+$submitform
+    return false;
 }
 END
         }
     }
     $js = &Apache::lonhtmlcommon::scripttag($js);
-    if (($passwdconf{'captcha'} eq 'recaptcha') && ($passwdconf{'recaptchaversion'} >=2)) {
-        $js.= "\n".'<script src="https://www.google.com/recaptcha/api.js"></script>'."\n";
+    if (($passwdconf{'captcha'} eq 'recaptcha') || ($passwdconf{'captcha'} eq 'hcaptcha') ||
+        ($passwdconf{'captcha'} eq 'turnstile')) {
+        my $pubkey;
+        if (ref($passwdconf{'recaptchakeys'}) eq 'HASH') {
+            $pubkey = $passwdconf{'recaptchakeys'}{'public'};
+        }
+        $js .= "\n".&Apache::loncommon::captcha_js($passwdconf{'captcha'},$passwdconf{'recaptchaversion'},$pubkey)."\n";
     }
     my $header = &Apache::loncommon::start_page('Reset password',$js,$args).
                  '<div class="LC_landmark" role="banner">'.
@@ -306,11 +325,13 @@
         } elsif (($uname) || ($useremail)) {
             my $earlyout;
             unless ($passwdconf{'captcha'} eq 'unused') {
-                my ($captcha_chk,$captcha_error) =
+                my ($captcha_chk,$captcha_error,$captcha) =
                     &Apache::loncommon::captcha_response('passwords',$server,$dom_in_effect);
                 if ($captcha_chk != 1) {
-                    my $error = 'captcha'; 
-                    if ($passwdconf{'captcha'} eq 'recaptcha') {
+                    my $error = 'captcha';
+                    if (($captcha eq 'recaptcha') ||
+                        ($captcha eq 'hcaptcha') ||
+                        ($captcha eq 'turnstile')) {
                         $error = 'recaptcha';
                     }
                     $output = &invalid_state($error,$domdesc,
@@ -414,10 +435,12 @@
                 }
             }
         } else {
-            $output = &get_uname($server,$dom_in_effect,\%passwdconf);
+            $output = &get_uname($formname,$server,$dom_in_effect,\%passwdconf,
+                                 $captcha_form,$hdn_captcha,$recaptcha_badge);
         }
     } else {
-        $output = &get_uname($server,$defdom,\%passwdconf);
+        $output = &get_uname($formname,$server,$defdom,\%passwdconf,
+                             $captcha_form,$hdn_captcha,$recaptcha_badge);
     }
     $r->print($header.'<div class="LC_landmark" role="main">'.$output.'</div>');
     $r->print(&Apache::loncommon::end_page());
@@ -425,7 +448,7 @@
 }
 
 sub get_uname {
-    my ($server,$defdom,$passwdconf) = @_;
+    my ($formname,$server,$defdom,$passwdconf,$captcha_form,$hdn_captcha,$recaptcha_badge) = @_;
     return unless (ref($passwdconf) eq 'HASH');
     my %lt = &Apache::lonlocal::texthash(
             unam => 'LON-CAPA username',
@@ -449,7 +472,8 @@
            .'<li>'.&mt('Your LON-CAPA account must be of a type for which LON-CAPA can reset a password.').'</li>'
            .'</ul>';
     my $onchange = 'javascript:verifyDomain(this,this.form);';
-    $msg .= '<form name="forgotpw" method="post" action="/adm/resetpw" onsubmit="return validInfo();">'.
+    my $validationtext = '';
+    $msg .= '<form name="'.$formname.'" method="post" action="/adm/resetpw" onsubmit="return validInfo();">'.
             &Apache::lonhtmlcommon::start_pick_box().
             &Apache::lonhtmlcommon::row_title('<label for="udom">'.$lt{'udom'}.'</label>').
             &Apache::loncommon::select_dom_form($defdom,'udom',undef,undef,$onchange,'','','','udom').
@@ -461,16 +485,17 @@
             '<input type="text" name="useremail" id="useremail" size="30" autocapitalize="off" autocorrect="off" />'.
             &Apache::lonhtmlcommon::row_closure(1);
     unless ($passwdconf->{'captcha'} eq 'notused') {
-        my ($captcha_form,$captcha_error,$captcha,$recaptcha_version) =
-            &Apache::loncommon::captcha_display('passwords',$server,$defdom);
         if ($captcha_form) {
             $msg .= &Apache::lonhtmlcommon::row_title($lt{'vali'}).
                     $captcha_form."\n".
                     &Apache::lonhtmlcommon::row_closure(1);
+        } else {
+            $validationtext = $recaptcha_badge;
         }
     }
-    $msg .= &Apache::lonhtmlcommon::end_pick_box().
-            '<br /><br /><input type="submit" name="resetter" value="'.$lt{'proc'}.'" /></form>';
+    $msg .= &Apache::lonhtmlcommon::end_pick_box().$validationtext.
+            '<br /><br /><input type="submit" name="resetter" value="'.$lt{'proc'}.'" />'."\n".
+            $hdn_captcha.'</form>';
     return $msg;
 }
 


More information about the LON-CAPA-cvs mailing list