【问题标题】:JSF 2 Captcha using <h:graphicImage rendering twice for Servlet generated image value working only for ChromeJSF 2 Captcha 使用 <h:graphicImage 渲染两次 Servlet 生成的图像值仅适用于 Chrome
【发布时间】:2026-02-06 04:00:02
【问题描述】:

我的应用程序中有一个问题,其中我有一个作为 JSF 自定义标签构建的验证码组件:

在我的 JavaEE 6 webapp 中,我使用: JSF 2.1 + Jboss Richfaces 4.2.3 + EJB 3.1 + JPA 2.0 + PrettyFaces 3.3.3

我有一个 JSF2 自定义标签:

<tag>
    <tag-name>captcha</tag-name>
    <source>tags/captcha.xhtml</source>
</tag>  

在我的名为 accountEdit.xhtml 的 XHTML 页面中,我显示了验证码:

                <ui:fragment rendered="#{customerMB.screenComponent.pageName eq 'create'}">
                    <div class="form_row">
                            <label class="contact"><strong>#{msg.captcha}:</strong>
                            </label>
                            <atl:captcha></atl:captcha>                     
                    </div>                                                          
                </ui:fragment>

在 captcha.xhtml 中:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
    xmlns:ui="http://java.sun.com/jsf/facelets"
    xmlns:h="http://java.sun.com/jsf/html"
    xmlns:f="http://java.sun.com/jsf/core"
    xmlns:a4j="http://richfaces.org/a4j"
    xmlns:rich="http://richfaces.org/rich">

    <table border="0">
        <tr>
            <td>
            <h:graphicImage id="capImg" value="#{facesContext.externalContext.requestContextPath}/../captcha.jpg" />
            </td>
            <td><a4j:commandButton id="resetCaptcha" value="#{msg.changeImage}" immediate="true" action="#{userMB.resetCaptcha}" >
                <a4j:ajax render="capImg" execute="@this" />                
            </a4j:commandButton></td>
        </tr>
        <tr>
            <td><h:inputText value="#{userMB.captchaComponent.captchaInputText}" /></td>            
        </tr>
    </table>

</ui:composition>

在我的 web.xml 中,我配置了一个 CaptchaServlet 来处理运行时生成验证码的请求:

<servlet>   
    <servlet-name>CaptchaServlet</servlet-name>
    <servlet-class>com.myapp.web.common.servlet.CaptchaServlet</servlet-class>      
    <init-param>
        <description>passing height</description>
        <param-name>height</param-name>
        <param-value>30</param-value>
    </init-param>
    <init-param>
        <description>passing width</description>
        <param-name>width</param-name>
        <param-value>120</param-value>
    </init-param>
    <load-on-startup>3</load-on-startup>
</servlet>


<servlet-mapping>
    <servlet-name>CaptchaServlet</servlet-name>
    <url-pattern>/captcha.jpg</url-pattern>
</servlet-mapping>

我的 CaptchaServlet 实现:

public class CaptchaServlet extends HttpServlet {

    /**
     * 
     */
    private static final long serialVersionUID = 6105436133454099605L;

    private int height = 0;
    private int width = 0;
    public static final String CAPTCHA_KEY = "captcha_key_name";

    @Override
    public void init(ServletConfig config) throws ServletException {
        super.init(config);
        height = Integer
                .parseInt(getServletConfig().getInitParameter("height"));
        width = Integer.parseInt(getServletConfig().getInitParameter("width"));
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse response)
            throws IOException, ServletException {

        // Expire response
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Max-Age", 0);

        BufferedImage image = new BufferedImage(width, height,
                BufferedImage.TYPE_INT_RGB);
        Graphics2D graphics2D = image.createGraphics();
        Hashtable<TextAttribute, Object> map = new Hashtable<TextAttribute, Object>();
        Random r = new Random();
        String token = Long.toString(Math.abs(r.nextLong()), 36);
        String ch = token.substring(0, 6);
        Color c = new Color(0.6662f, 0.4569f, 0.3232f);
        GradientPaint gp = new GradientPaint(30, 30, c, 15, 25, Color.white,
                true);
        graphics2D.setPaint(gp);
        Font font = new Font("Verdana", Font.CENTER_BASELINE, 26);
        graphics2D.setFont(font);
        graphics2D.drawString(ch, 2, 20);
        graphics2D.dispose();
        HttpSession session = req.getSession(true);
        session.setAttribute(CAPTCHA_KEY, ch);

        OutputStream outputStream = response.getOutputStream();
        ImageIO.write(image, "jpeg", outputStream);
        outputStream.close();
    }
}

当我在 Glassfish 3.1.1 上运行此应用程序时 渲染时调用 Servlet 的 doGet() 方法时

对于渲染的 HttpServlet doGet() 方法:

<h:graphicImage id="capImg" value="#{facesContext.externalContext.requestContextPath}/../captcha.jpg" />

doGet() 只为 Google Chrome 渲染一次,因此渲染正确。

对于 Firefox 和 IE,doGet() 渲染两次更新验证码密钥,但不更新页面上绘制的验证码图像。

如果有人可能知道什么可以解决这个问题,以及为什么 Chrome 的这种行为不同于其他浏览器,请告诉我。

提前致谢!

【问题讨论】:

    标签: jsf-2 java-ee-6 graphics2d servlet-3.0 prettyfaces


    【解决方案1】:

    浏览器正在缓存响应。您避免这种情况的尝试是不完整且不正确的:

    response.setHeader("Cache-Control", "no-cache");
    response.setDateHeader("Expires", 0);
    response.setHeader("Pragma", "no-cache");
    response.setDateHeader("Max-Age", 0);
    

    请参考How to control web page caching, across all browsers?获取正确的设置:

    response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); // HTTP 1.1.
    response.setHeader("Pragma", "no-cache"); // HTTP 1.0.
    response.setDateHeader("Expires", 0); // Proxies.
    

    此外,为了使其更加健壮,请在图像 URL 中添加一个带有当前时间戳的查询字符串(以毫秒为单位)。这是一个示例,前提是您有一个 java.util.Date 实例作为托管 bean,名称为 now

    <h:graphicImage id="capImg" value="#{request.contextPath}/../captcha.jpg?#{now.time}" />
    

    (请注意,我还简化了获取请求上下文路径的方法,我只是不明白如果你通过../ 转到域根目录有什么用处)

    【讨论】:

    • OP 的工作基于 2010 年的 this blog post,除了存在您所说的所有缓存问题外,还滥用 session 作为验证码值的持有者。
    • @skuntsel:我明白了。会话范围并没有完全被滥用,无论如何它都应该存储在会话范围中,但它确实使用错误的方式。例如。如果您打开带有验证码的页面的 2 个浏览器选项卡,并在打开第二个选项卡后返回到第一个选项卡,则提交将失败,因为它需要第二个选项卡中的验证码值。解决方案是使用一个自动生成的密钥,该密钥存储在隐藏的输入字段中(或在 JSF 术语中的视图范围内),或者只是使用现有且健全的 JSF 组件:) PrimeFaces 有一个。
    • 感谢您的输入,BalusC 我尝试了您的第一个建议,但它仍然碰巧渲染了两次,因为 graphicsImage 值调用 CaptchaServlet 的 doGet() 方法的两倍,覆盖了 CAPTCHA_KEY 会话对象但没有重新在屏幕中渲染 img。我试图避免仅仅为了使用这个单一组件而包含 PrimeFaces,我将尝试基于不同的方法构建一个。
    • 我尝试按照jcaptcha.atlassian.net/wiki/display/general/… 的示例使用 JCaptcha - 仍然发生同样的问题,当我导航到页面时,doGet() 方法被调用两次,从而呈现 2 个不同的 Catpcha 响应键,覆盖第一个,但不为新生成的验证码重新渲染页面中的图像。奇怪的是,只有在 Chrome 中它才能正常工作,我仍然反对使用 PrimeFaces,因为它使用 reCaptcha 并且只能在线工作。
    • 我找到了一种解决方法,它使用 HashMap 来存储 (id, captchaImage) 并为它们提供服务,以防它是相同的请求,并且每当有人点击一个指向包含一个页面的按钮时刷新 HashMap验证码,我知道这不是理想的解决方案,但同时我会坚持下去。
    【解决方案2】:

    我找到了一个解决方案,不是最佳解决方案,但它有效,在这里:

    验证码.xhtml

    <table border="0">
        <tr>
            <td>
                <h:graphicImage url="#{request.contextPath}/../jcaptcha"/>
            </td>
            <td>
                <input type='text' name='j_captcha_response' value='' />
            </td>
        </tr>
    </table>
    

    CaptchaServlet doGet 方法:

        protected void doGet(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException {
    
            byte[] captchaChallengeAsJpeg = null;
            // the output stream to render the captcha image as jpeg into
            ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream();
            try {
            // get the session id that will identify the generated captcha.
            //the same id must be used to validate the response, the session id is a good candidate!
            String captchaId = httpServletRequest.getSession().getId();
                // call the ImageCaptchaService getChallenge method
                BufferedImage challenge =
                        CaptchaServiceSingleton.getImageChallengeForID(captchaId,
                                httpServletRequest.getLocale());
                // a jpeg encoder
                JPEGImageEncoder jpegEncoder =
                        JPEGCodec.createJPEGEncoder(jpegOutputStream);
                jpegEncoder.encode(challenge);
            } catch (IllegalArgumentException e) {
                httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
                return;
            } catch (CaptchaServiceException e) {
                httpServletResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                return;
            }
            captchaChallengeAsJpeg = jpegOutputStream.toByteArray();
    
            // flush it in the response
            httpServletResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); // HTTP 1.1.
            httpServletResponse.setHeader("Pragma", "no-cache");
            httpServletResponse.setDateHeader("Expires", 0);
            httpServletResponse.setContentType("image/jpeg");
            ServletOutputStream responseOutputStream =
                    httpServletResponse.getOutputStream();
            responseOutputStream.write(captchaChallengeAsJpeg);
            responseOutputStream.flush();
            responseOutputStream.close();
        }
    

    创建 CaptchaServiceRequestSingleton.java

        package com.myapp.web.common.listener;
    
        import java.awt.image.BufferedImage;
        import java.util.HashMap;
        import java.util.Locale;
    
        import com.octo.captcha.service.image.DefaultManageableImageCaptchaService;
        import com.octo.captcha.service.image.ImageCaptchaService;
    
    public class CaptchaServiceSingleton {
    
        private static ImageCaptchaService instance = new DefaultManageableImageCaptchaService();
        private static final int MAX_CACHE_SIZE = 200;
        private static HashMap<String, BufferedImage> captchaImgCache = new HashMap<String, BufferedImage>();
    
        public static ImageCaptchaService getInstance(){
            return instance;
        }
    
        public static BufferedImage getImageChallengeForID(String id, Locale locale) {
            if (captchaImgCache.containsKey(id)) {
                return captchaImgCache.get(id);
            } else {
                BufferedImage bImage = instance.getImageChallengeForID(id, locale);
    
                // if limit reached reset captcha cache
                if (captchaImgCache.size() > MAX_CACHE_SIZE) {
                    captchaImgCache = new HashMap<String, BufferedImage>();
                }
    
                captchaImgCache.put(id, bImage);
                return bImage;
            }
        }
    
        public static void resetImageChallengeForID(String id) {        
            if (captchaImgCache.containsKey(id)) {      
                captchaImgCache.remove(id);
            }               
        }
    
    }
    

    当点击“创建帐户”按钮时验证码被重置:

    CustomerMB.openCreateCustomerAccount():

    public String openCreateCustomerAccount() {
        customerAccountEditVO = new CustomerAccountVO();
        screenComponent.setPageName(NameConstants.CREATE);
        getUserMB().resetCaptcha();
        return null;
    }
    

    在 UserMB.resetCaptcha() 中:

    public String resetCaptcha() {
        CaptchaServiceSingleton.resetImageChallengeForID(JSFUtil.getRequest().getRequestedSessionId());     
        return null;
    }
    

    也许这不是完美的解决方案,但至少它适用于所有浏览器。

    【讨论】:

    • 这种方法有什么缺点?
    • 我不记得我当时在想什么,我猜想关于 ajax 或后退按钮处理支持的缺点,但总的来说它非常适合我所需的场景。跨度>