news 2026/9/24 14:58:14

Apereo CAS 图形用户认证(GUA)实战指南:登录图片二次验证的原理、配置与源码剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Apereo CAS 图形用户认证(GUA)实战指南:登录图片二次验证的原理、配置与源码剖析
  • 后端
  • 认证鉴权
  • 单点登录

【免费下载链接】cas

Apereo CAS - Identity & Single Sign On for all earthlings and beyond.

项目地址:https://gitcode.com/gh_mirrors/ca/cas
点击查看免费下载

导读

GUA(Graphical User Authentication,图形用户认证),也叫"登录图片"(login images),是 Apereo CAS 提供的一种轻量级第二因子验证方案:用户在创建账户时预选一张图片作为"账户秘密",登录时 CAS 先让用户输入用户名,然后展示该用户预设的图片并要求用户继续输入密码——如果展示的图片与用户记忆中预选的图片不一致,用户应拒绝提交剩余凭据,从而有效抵御钓鱼网站冒充合法站点。本文以 GUA-Authentication.md 为主体,结合仓库中cas-server-support-gua模块的源码、配置模型与测试用例,完整讲解 GUA 的原理、启用方式、静态资源(Resource)与 LDAP 两种图片存储方案的配置,以及底层 Webflow 登录流程的实现细节。

GUA 是什么:把"图片"变成账户秘密

图形用户认证在本质上是一种"共享秘密"(shared secret)式的登录校验手段。它的核心思想是:

  • 在账户创建阶段,用户从站点提供的图片池中预先选择一张属于自己的图片,这张图片与用户名绑定,作为账户的专属标识;
  • 登录时,站点只要求用户输入用户名,随即返回一页展示该用户名对应的预选图片,同时提供密码输入框;
  • 用户被训练为:只有当页面展示的图片确实是自己当初选择的那张时,才继续提交密码;否则应当立即停止操作并离开该站点。

正如 GUA-Authentication.md 中所强调的,图片是"与用户名绑定的账户秘密"(an "account secret" tied to the username),它"不应该被钓鱼攻击轻易复现"(should not be easily reproduced by a phishing campaign attempting to impersonate a legitimate website)。钓鱼网站即使伪造出与合法站点完全一致的登录页面,也无法预知受害者预选了哪张图片,因此无法通过"图片确认"这一关。

需要说明的是,GUA 通常被定位为第二因子(second factor)性质的校验:它不替代用户名密码认证,而是在正式认证之前增加一道"图片确认"环节,属于 认证(Authentication) 类别下的可选增强能力。

启用 GUA:引入 cas-server-support-gua 模块

GUA 支持通过在 CAS overlay 项目中引入以下模块来启用(Gradle 坐标来自官方文档的模块说明):

implementation "org.apereo.cas:cas-server-support-gua"

该模块在仓库中的物理位置为 support/cas-server-support-gua,其自动配置入口为 CasGraphicalUserAuthenticationAutoConfiguration.java,类上的注解体现了两个关键前提:

  • @ConditionalOnFeatureEnabled(feature = CasFeatureModule.FeatureCatalog.Authentication, module = "gua"):GUA 属于 Authentication 特性域下的gua子特性,需在全局特性开关启用时才会生效;
  • @AutoConfiguration:Spring Boot 自动装配,模块引入后无需手动注册 Bean。

模块引入后,CAS 会在登录 Webflow 中自动注入 GUA 相关动作与状态(详见下文"登录流程"一节)。

图片存储方案一:静态资源(Static Resource)

适用场景与原理

静态资源方案 的定位非常明确:主要用于演示(demo)和测试(testing)目的。它允许 CAS 把一个"全局且静态"的图片资源作为用户标识加载到登录流程中。

"全局且静态"的含义是:这种方式不区分图片的所属用户——配置中通过一个以用户名为键、图片资源路径为值的 Map 来为不同用户绑定不同的图片,配置简单直接,适合开发调试、演示环境和自动化测试,但不适合生产环境大规模管理用户图片。

配置方式

application.properties(或 YAML)中,通过cas.authn.gua.simple配置项指定用户名与图片资源的映射:

cas.authn.gua.simple.casuser=file:/etc/cas/config/images/casuser.jpg cas.authn.gua.simple.alice=classpath:images/alice.png cas.authn.gua.simple.bob=https://example.org/images/bob.jpg

cas.authn.gua.simple在配置模型中的定义见 GraphicalUserAuthenticationProperties.java:

/** * Locate GUA settings and images from a static image per user. * This is treated as a {@link Map} where the key is the user id * and the value should be the graphical resource. */ private Map<String, String> simple = new LinkedHashMap<>();

也就是说,simple是一个Map<String, String>key 是用户标识,value 是图片资源位置。value 支持 Spring 的资源定位语法,包括:

前缀含义示例
file:文件系统路径file:/etc/cas/images/casuser.jpg
classpath:类路径资源(如打成 jar 内嵌的图片)classpath:images/casuser.jpg
http(s)://远程 URL 资源https://example.org/images/casuser.jpg

图片资源会被解析为 Spring 的Resource对象。从 CasGraphicalUserAuthenticationAutoConfiguration.java 可以看到,当gua.getSimple()非空时,自动配置会优先选择静态资源仓库:

@Bean @RefreshScope(proxyMode = ScopedProxyMode.DEFAULT) @ConditionalOnMissingBean(name = "userGraphicalAuthenticationRepository") public UserGraphicalAuthenticationRepository userGraphicalAuthenticationRepository( final CasConfigurationProperties casProperties) { val gua = casProperties.getAuthn().getGua(); if (!gua.getSimple().isEmpty()) { val accounts = gua.getSimple().entrySet().stream().map(Unchecked.function(entry -> { val res = ResourceUtils.getResourceFrom(entry.getValue()); return Pair.of(entry.getKey(), (Resource) res); })).collect(Collectors.toMap(Pair::getKey, Pair::getValue)); return new StaticUserGraphicalAuthenticationRepository(accounts); } // ...LDAP 分支... throw new BeanCreationException("A repository instance must be configured to locate user-defined graphics"); }

底层实现:StaticUserGraphicalAuthenticationRepository

静态图片的读取逻辑位于 StaticUserGraphicalAuthenticationRepository.java。它实现了统一的仓库接口 UserGraphicalAuthenticationRepository(该接口为函数式接口,核心方法只有一个ByteSource getGraphics(String username)):

@Override public ByteSource getGraphics(final String username) { try (val resourceStream = graphicResource.get(username).getInputStream(); val bos = new ByteArrayOutputStream()) { IOUtils.copy(resourceStream, bos); return ByteSource.wrap(bos.toByteArray()); } catch (final Exception e) { LoggingUtils.error(LOGGER, e); } return ByteSource.empty(); }

实现要点:

  • 以用户名为 key 从Map<String, Resource>中取出图片资源,读取为字节流并包装成 Guava 的ByteSource
  • 如果用户不存在、资源缺失或读取失败,返回ByteSource.empty(),不会抛出异常;
  • 对应的单元测试 StaticUserGraphicalAuthenticationRepositoryTests.java 验证了"存在图片返回非空"与"图片缺失返回空"两种行为:
    • verifyImage:以Map.of("casuser", new ClassPathResource("image.jpg"))构造仓库,断言getGraphics("casuser")非空;
    • verifyBadImage:以不存在的missing.jpg构造,断言返回空。

在测试基类 AbstractGraphicalAuthenticationTests.java 中,静态方案的实际配置写法为:

cas.authn.gua.simple.casuser=classpath:image.jpg

这正好可以作为最小可运行的参考配置。

图片存储方案二:LDAP 二进制属性

适用场景与原理

LDAP 方案 允许 CAS 从 LDAP 目录中定位用户的二进制图片属性(binary image attribute),将该二进制属性值作为用户标识加载到登录流程。相比静态资源方案,LDAP 方案把图片作为用户目录数据的一部分统一管理,更贴近生产环境,适合已有 LDAP/AD 目录的机构。

配置方式

LDAP 方案通过cas.authn.gua.ldap配置项启用。核心配置包括:

cas.authn.gua.ldap.ldap-url=ldap://localhost:10389 cas.authn.gua.ldap.base-dn=dc=example,dc=org cas.authn.gua.ldap.search-filter=cn={user} cas.authn.gua.ldap.image-attribute=jpegPhoto cas.authn.gua.ldap.bind-dn=cn=Directory Manager cas.authn.gua.ldap.bind-credential=password

参数说明:

配置项是否必填说明
cas.authn.gua.ldap.ldap-urlLDAP 服务器地址,多个地址可用空格或逗号分隔,支持ACTIVE_PASSIVEROUND_ROBINRANDOMDNS_SRV等连接策略
cas.authn.gua.ldap.base-dn搜索的基础 DN,可配置多个子树并用\|分隔(如subtreeA,dc=example,dc=net\|subtreeC,dc=example,dc=net
cas.authn.gua.ldap.search-filter用户搜索过滤器,语法为cn={user}cn={0};也支持file:/path/to/GroovyScript.groovy外部脚本动态构造过滤器
cas.authn.gua.ldap.image-attribute存放用户图片的条目属性名(如jpegPhoto),该属性必须是二进制类型
cas.authn.gua.ldap.bind-dn/bind-credential连接 LDAP 的绑定凭据;置空表示匿名操作,设为*表示 fast-bind 策略
cas.authn.gua.ldap.subtree-search否(默认true是否允许子树搜索
cas.authn.gua.ldap.page-size分页请求大小,用于规避服务器结果集大小限制,负值/零值禁用分页
cas.authn.gua.ldap.use-start-tls否(默认false是否启用 StartTLS
cas.authn.gua.ldap.connect-timeout/response-timeout否(默认PT5S连接与响应超时
cas.authn.gua.ldap.trust-certificatestrust-storeLDAPS/StartTLS 场景下的证书信任配置

image-attribute是 LDAP 方案独有且必填的属性,定义见 LdapGraphicalUserAuthenticationProperties.java:

/** * Entry attribute that holds the user image. */ @RequiredProperty private String imageAttribute;

其余 LDAP 搜索与连接参数继承自 AbstractLdapSearchProperties 与 AbstractLdapProperties,CAS 其他 LDAP 相关模块(认证、属性仓库、服务注册等)也复用同一套参数体系。

底层实现:LdapUserGraphicalAuthenticationRepository

LDAP 图片的检索逻辑位于 LdapUserGraphicalAuthenticationRepository.java:

@Override public ByteSource getGraphics(final String username) { val gua = casProperties.getAuthn().getGua(); val response = searchForId(username); if (LdapUtils.containsResultEntry(response)) { val entry = response.getEntry(); val attribute = entry.getAttribute(gua.getLdap().getImageAttribute()); if (attribute != null && attribute.isBinary()) { return ByteSource.wrap(attribute.getBinaryValue()); } } return ByteSource.empty(); } private SearchResponse searchForId(final String id) { return FunctionUtils.doUnchecked(() -> { val gua = casProperties.getAuthn().getGua(); val filter = LdapUtils.newLdaptiveSearchFilter(gua.getLdap().getSearchFilter(), LdapUtils.LDAP_SEARCH_FILTER_DEFAULT_PARAM_NAME, CollectionUtils.wrap(id)); return connectionFactory.executeSearchOperation( gua.getLdap().getBaseDn(), filter, gua.getLdap().getPageSize(), new String[]{gua.getLdap().getImageAttribute()}, ReturnAttributes.ALL_USER.value()); }); }

实现要点:

  • 使用配置中的searchFiltercn={user}形式)与baseDn发起搜索,返回属性限定为imageAttribute
  • 检索到条目后,读取imageAttribute属性的二进制值(attribute.isBinary()检查)并包装为ByteSource;若用户不存在或属性缺失,同样返回空;
  • 该类同时实现DisposableBean,在销毁时关闭LdapConnectionFactory释放连接池资源;
  • 从 CasGraphicalUserAuthenticationAutoConfiguration.java 可见,LDAP 分支的启用条件是ldapUrlsearchFilterbaseDnimageAttribute四个属性全部非空;若simple与 LDAP 配置都不满足,启动时抛出BeanCreationException("A repository instance must be configured to locate user-defined graphics")。

测试验证:jpegPhoto 属性的端到端验证

仓库中的集成测试 LdapUserGraphicalAuthenticationRepositoryTests.java 提供了完整的验证思路(该测试依赖本机10389端口可用的 LDAP 服务,由@EnabledIfListeningOnPort(port = 10389)控制):

  1. 使用与上面一致的 LDAP 配置初始化 Spring 上下文;
  2. createLdapEntry方法向目录中添加一个inetOrgPerson条目,其jpegPhoto属性写入image.jpg的字节内容;
  3. 断言getGraphics(cn)返回非空,而getGraphics("bad-user")返回空。

这从测试层面印证了:LDAP 条目中的二进制图片属性(典型如jpegPhoto)就是 GUA 图片的承载介质,目录管理员只需保证用户条目存在且图片属性为二进制格式即可。

GUA 在登录 Webflow 中的位置:三段式流程

引入模块后,GraphicalUserAuthenticationWebflowConfigurer.java 会把 GUA 流程织入 CAS 登录流程(login flow),整体流程为:

登录表单(initLoginForm) --GUA_PREPARE_LOGIN--> 输入用户名(casGuaGetUserIdView) --> 展示图片+密码框(casGuaDisplayUserGraphicsView) --> 接受图片(AcceptUserGraphics) --> 回到原认证流程

具体的织入逻辑(doInitialize方法):

  • initLoginForm状态的动作列表中前置追加ACTION_ID_GUA_PREPARE_LOGIN动作;
  • 为登录表单状态新增一条GUA_GET_USERID转换,指向新建的视图状态gua/casGuaGetUserIdView(即"先输用户名"页面);
  • 从用户名视图提交后进入gua/casGuaDisplayUserGraphicsView(即"展示图片+密码"页面),并在该视图的渲染动作列表中注册DISPLAY_USER_GRAPHICS_BEFORE_AUTHENTICATION动作;
  • 图片页提交后进入ACCEPT_GUA动作状态(ACCEPT_USER动作),随后默认转换回到原登录表单状态原本的 success 目标状态,继续执行常规的 Username/Password 认证。

三个核心 Action 的职责

1. PrepareForGraphicalAuthenticationAction(流程入口)

PrepareForGraphicalAuthenticationAction.java 在登录表单渲染前执行:

  • 通过WebUtils.putGraphicalUserAuthenticationEnabled(requestContext, Boolean.TRUE)标记 GUA 已启用;
  • 若当前流程上下文中还没有 GUA 用户名,则触发GUA_GET_USERID转换,把用户导向"仅输入用户名"的页面;否则放行继续。

2. DisplayUserGraphicsBeforeAuthenticationAction(取图与校验)

DisplayUserGraphicsBeforeAuthenticationAction.java 是 GUA 的核心校验动作:

@Override protected @Nullable Event doExecuteInternal(final RequestContext requestContext) throws Exception { val username = requestContext.getRequestParameters().get("username"); if (StringUtils.isBlank(username)) { throw UnauthorizedServiceException.denied("Denied"); } val graphics = repository.getGraphics(username); if (graphics == null || graphics.isEmpty()) { throw UnauthorizedServiceException.denied("Denied"); } val image = EncodingUtils.encodeBase64ToByteArray(graphics.read()); WebUtils.putGraphicalUserAuthenticationUsername(requestContext, username); WebUtils.putGraphicalUserAuthenticationImage(requestContext, new String(image, StandardCharsets.UTF_8)); return success(); }

要点:

  • 从请求参数中取出用户名,为空直接抛出UnauthorizedServiceException.denied("Denied")
  • 调用仓库接口getGraphics(username)取图,图片为空同样直接拒绝——这意味着用户名错误或没有绑定图片的用户无法通过 GUA 环节;
  • 取图成功后,将图片字节流做Base64 编码,连同用户名一起放入 Webflow 请求上下文,供视图层渲染。

对应的测试 DisplayUserGraphicsBeforeAuthenticationActionTests.java 验证了"正常返回 success 且上下文中包含图片与用户名"以及"缺少用户名时抛出 UnauthorizedServiceException"两个分支。

3. AcceptUserGraphicsForAuthenticationAction(衔接主认证)

AcceptUserGraphicsForAuthenticationAction.java 在用户确认图片并提交密码页后执行:

  • 以当前用户名构造UsernamePasswordCredential(username, null)放入 Webflow 上下文(密码由后续密码页收集);
  • 再次记录 GUA 用户名,返回 success,流程回到登录表单的原始 success 目标状态继续常规认证。

从源码结构看,GUA 的定位是"主认证之前的图片确认闸门":图片校验通过后,认证依旧走标准的用户名密码路径,因此 GUA 与 LDAP/JDBC/静态等认证处理器天然兼容。

前端视图与多语言提示

GUA 的两个页面视图由 thymeleaf 模块提供:

  • 用户名输入页 casGuaGetUserIdView.html:一个只含用户名字段的表单,提交事件为_eventId_submit
  • 图片展示页 casGuaDisplayUserGraphicsView.html:
<h2 class="text-center" th:text="${guaUsername}">guaUsername</h2> <div id="guaInfo" class="banner banner-danger alert alert-danger d-flex m-4 p-4" role="alert"> <i class="mdi mdi-alert-octagon fas fa-exclamation-circle" aria-hidden="true"></i> <strong th:utext="#{screen.gua.confirm.message}"> If you do not recognize this image as yours, do NOT continue.</strong> </div> <img id="guaImage" style="width:130px;height:130px;" th:src="@{'data:image/jpeg;base64,' + ${guaUserImage}}" alt="User graphic" />

值得注意的细节:

  • 图片通过data:image/jpeg;base64,...内联 data URI渲染,这正是前面DisplayUserGraphicsBeforeAuthenticationAction中 Base64 编码的原因——图片不经过额外 HTTP 请求,直接内嵌在页面中;
  • 页面在图片旁以醒目的警示样式展示提示语:"If you do not recognize this image as yours, do NOT continue."(如果你不确认这张图片是你自己的,请不要继续)。这正是 GUA 对抗钓鱼的核心用户教育话术;
  • 该提示语通过screen.gua.confirm.message国际化键管理,仓库在 messages.properties 以及messages_zh_CN.propertiesmessages_fr.properties等 30 余种语言文件中均提供了翻译(如中文:"如果您无法识别该图像是您的,请不要继续。"),多语言环境开箱即用;
  • 图片展示页同样提交_eventId_submit(Continue),由AcceptUserGraphicsForAuthenticationAction承接。

配置速查与注意事项

两种方案的启用逻辑总结

方案配置前缀判定条件(自动配置)典型用途
静态资源cas.authn.gua.simplesimpleMap 非空即启用演示、测试、开发调试
LDAPcas.authn.gua.ldapldapUrlsearchFilterbaseDnimageAttribute全部非空生产环境、已有目录服务

两者的优先级(见 CasGraphicalUserAuthenticationAutoConfiguration.java):静态方案优先;两者都未配置时启动直接失败并提示必须配置图片仓库。

使用建议

  • 静态资源方案中的 value 建议优先使用file:绝对路径或classpath:资源,避免生产环境对远程 URL 的强依赖;
  • LDAP 方案的image-attribute必须指向二进制类型的属性(实现代码中显式检查了attribute.isBinary()),最典型的是jpegPhoto(OpenLDAP/389ds 等目录的常用照片属性);
  • 图片展示页的<img>标签固定使用data:image/jpeg;base64前缀,因此建议图片统一使用 JPEG 格式,以兼容该内联渲染方式;
  • GUA 环节中用户名缺失或图片缺失都会被拒绝(UnauthorizedServiceException.denied),因此请确保启用 GUA 前所有目标用户的图片数据完整;
  • 若同时启用 GUA 与其他登录方式,GUA 仅作为登录流程的前置步骤,不影响 CAS 既有的认证处理器、MFA 等后续环节的编排。

小结

GUA 是 Apereo CAS 提供的一种轻量、易落地的钓鱼防御手段:它把"预选图片"转化为绑定用户名的账户秘密,在密码提交前增加一道图片确认关卡。本文从官方文档出发,结合仓库源码完整梳理了它的概念模型、模块引入方式、静态资源与 LDAP 两种图片存储方案的配置与底层实现(仓库接口UserGraphicalAuthenticationRepository与两个实现类)、Webflow 三段式登录流程(GUA_PREPARE_LOGINDISPLAY_USER_GRAPHICS_BEFORE_AUTHENTICATIONACCEPT_USER)、Base64 内联渲染机制与多语言提示,并给出了测试用例作为配置正确性的可验证依据。无论是搭建演示环境(cas.authn.gua.simple)还是对接生产目录(cas.authn.gua.ldap+jpegPhoto),都可以参照上文配置快速落地。

  • 后端
  • 认证鉴权
  • 单点登录

【免费下载链接】cas

Apereo CAS - Identity & Single Sign On for all earthlings and beyond.

项目地址:https://gitcode.com/gh_mirrors/ca/cas
点击查看免费下载
上一篇:Material 框架架构深度剖析:从 ViewController 继承体系到 Themeable 协议的源码级解读
下一篇:TheRemoteFreelancer终极对比指南:2025年远程工作平台深度评测

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 14:56:54

QEMU模拟STM32实战:从LED闪烁到工业级嵌入式验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 14:56:54

FPGA_GTY_SerDes_高速串并转换技术说明

1. 概述高速串行通信的核心目标&#xff0c;是解决通信系统中“并行数据位宽大、走线多”与“高速传输希望减少布线数量”之间的矛盾。传统并行接口可能需要&#xff1a;D0 D1 D2 ... D31 CLK Control如果采用高速串行通信&#xff0c;则可以把多位并行数据转换为一条或少量几条…

作者头像 李华
网站建设 2026/9/24 14:55:47

十年iOS开发实战复盘:从Objective-C到Swift与跨平台演进

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华