定制 Spring Security 错误提示信息

使用过 Spring Security 框架的同学应该都见过这个提示:坏的凭证,对应的英文提示为 Bad credentials。不知道是不是机翻,反正我看着十分别扭。客户拿到你做的系统,输入错误的用户名或密码时,看到坏的凭证这样的提示肯定是一头雾水。

下面就来教大家如何定制 Spring Security 的错误提示信息。

分析

spring-security-core-5.0.7.RELEASE.jarorg.springframework.security 包下,有一组名为 messages[_language_country].properties 的配置文件。

messages_zh_CN.properties 内容如下:

AbstractAccessDecisionManager.accessDenied=不允许访问
AbstractLdapAuthenticationProvider.emptyPassword=坏的凭证
AbstractSecurityInterceptor.authenticationNotFound=未在SecurityContext中查找到认证对象
AbstractUserDetailsAuthenticationProvider.badCredentials=坏的凭证
AbstractUserDetailsAuthenticationProvider.credentialsExpired=用户凭证已过期
AbstractUserDetailsAuthenticationProvider.disabled=用户已失效
AbstractUserDetailsAuthenticationProvider.expired=用户帐号已过期
AbstractUserDetailsAuthenticationProvider.locked=用户帐号已被锁定
AbstractUserDetailsAuthenticationProvider.onlySupports=仅仅支持UsernamePasswordAuthenticationToken
AccountStatusUserDetailsChecker.credentialsExpired=用户凭证已过期
AccountStatusUserDetailsChecker.disabled=用户已失效
AccountStatusUserDetailsChecker.expired=用户帐号已过期
AccountStatusUserDetailsChecker.locked=用户帐号已被锁定
AclEntryAfterInvocationProvider.noPermission=给定的Authentication对象({0})根本无权操控领域对象({1})
AnonymousAuthenticationProvider.incorrectKey=展示的AnonymousAuthenticationToken不含有预期的key
BindAuthenticator.badCredentials=坏的凭证
BindAuthenticator.emptyPassword=坏的凭证
CasAuthenticationProvider.incorrectKey=展示的CasAuthenticationToken不含有预期的key
CasAuthenticationProvider.noServiceTicket=未能够正确提供待验证的CAS服务票根
ConcurrentSessionControlAuthenticationStrategy.exceededAllowed=已经超过了当前主体({0})被允许的最大会话数量
DigestAuthenticationFilter.incorrectRealm=响应结果中的Realm名字({0})同系统指定的Realm名字({1})不吻合
DigestAuthenticationFilter.incorrectResponse=错误的响应结果
DigestAuthenticationFilter.missingAuth=遗漏了针对'auth' QOP的、必须给定的摘要取值; 接收到的头信息为{0}
DigestAuthenticationFilter.missingMandatory=遗漏了必须给定的摘要取值; 接收到的头信息为{0}
DigestAuthenticationFilter.nonceCompromised=Nonce令牌已经存在问题了,{0}
DigestAuthenticationFilter.nonceEncoding=Nonce未经过Base64编码; 相应的nonce取值为 {0}
DigestAuthenticationFilter.nonceExpired=Nonce已经过期/超时
DigestAuthenticationFilter.nonceNotNumeric=Nonce令牌的第1部分应该是数字,但结果却是{0}
DigestAuthenticationFilter.nonceNotTwoTokens=Nonce应该由两部分取值构成,但结果却是{0}
DigestAuthenticationFilter.usernameNotFound=用户名{0}未找到
JdbcDaoImpl.noAuthority=没有为用户{0}指定角色
JdbcDaoImpl.notFound=未找到用户{0}
LdapAuthenticationProvider.badCredentials=坏的凭证
LdapAuthenticationProvider.credentialsExpired=用户凭证已过期
LdapAuthenticationProvider.disabled=用户已失效
LdapAuthenticationProvider.expired=用户帐号已过期
LdapAuthenticationProvider.locked=用户帐号已被锁定
LdapAuthenticationProvider.emptyUsername=用户名不允许为空
LdapAuthenticationProvider.onlySupports=仅仅支持UsernamePasswordAuthenticationToken
PasswordComparisonAuthenticator.badCredentials=坏的凭证
#PersistentTokenBasedRememberMeServices.cookieStolen=Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack.
ProviderManager.providerNotFound=未查找到针对{0}的AuthenticationProvider
RememberMeAuthenticationProvider.incorrectKey=展示RememberMeAuthenticationToken不含有预期的key
RunAsImplAuthenticationProvider.incorrectKey=展示的RunAsUserToken不含有预期的key
SubjectDnX509PrincipalExtractor.noMatching=未在subjectDN\: {0}中找到匹配的模式
SwitchUserFilter.noCurrentUser=不存在当前用户
SwitchUserFilter.noOriginalAuthentication=不能够查找到原先的已认证对象

Spring Security 通过 org.springframework.security.core.SpringSecurityMessageSource 类读取该组配置文件,将其作为 MessageSource 使用:

/**
 * The default <code>MessageSource</code> used by Spring Security.
 * <p>
 * All Spring Security classes requiring message localization will by default use this
 * class. However, all such classes will also implement <code>MessageSourceAware</code> so
 * that the application context can inject an alternative message source. Therefore this
 * class is only used when the deployment environment has not specified an alternative
 * message source.
 * </p>
 *
 * @author Ben Alex
 */
public class SpringSecurityMessageSource extends ResourceBundleMessageSource {

    public SpringSecurityMessageSource() {
        setBasename("org.springframework.security.messages");
    }

    public static MessageSourceAccessor getAccessor() {
        return new MessageSourceAccessor(new SpringSecurityMessageSource());
    }
}

Spring Security 的很多类都会调用 SpringSecurityMessageSource.getAccessor() 方法来获取 MessageSourceAccessor 的对象,进而使用 getMessage(...) 方法访问前述的配置文件信息:

/**
 * Helper class for easy access to messages from a MessageSource,
 * providing various overloaded getMessage methods.
 *
 * <p>Available from ApplicationObjectSupport, but also reusable
 * as a standalone helper to delegate to in application objects.
 *
 * @author Juergen Hoeller
 * @since 23.10.2003
 * @see ApplicationObjectSupport#getMessageSourceAccessor
 */
public class MessageSourceAccessor {

    private final MessageSource messageSource;

    @Nullable
    private final Locale defaultLocale;


    /**
     * Create a new MessageSourceAccessor, using LocaleContextHolder's locale
     * as default locale.
     * @param messageSource the MessageSource to wrap
     * @see org.springframework.context.i18n.LocaleContextHolder#getLocale()
     */
    public MessageSourceAccessor(MessageSource messageSource) {
        this.messageSource = messageSource;
        this.defaultLocale = null;
    }

    /**
     * Retrieve the message for the given code and the default Locale.
     * @param code code of the message
     * @param defaultMessage String to return if the lookup fails
     * @return the message
     */
    public String getMessage(String code, String defaultMessage) {
        String msg = this.messageSource.getMessage(code, null, defaultMessage, getDefaultLocale());
        return (msg != null ? msg : "");
    }

    //……
}

踩坑

SpringSecurityMessageSource 类的注释是这样说的:

The default MessageSource used by Spring Security.
All Spring Security classes requiring message localization will by default use this class. However, all such classes will also implement MessageSourceAware so that the application context can inject an alternative message source. Therefore this class is only used when the deployment environment has not specified an alternative message source.

Spring Security 默认使用的 MessageSource
所有需要本地化信息的 Spring Security 类默认使用该类。不过,这些类也都实现了 MessageSourceAware,以便应用程序上下文可以注入一个可供替代的信息源(message source)。因此这个类只在部署环境没有另一个信息源时使用。

再结合官方手册的说明 https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#localization,我们知道,可以提供一个名为 messageSource 的 bean,该 bean 通过 MessageSourceAware 接口自动注入到 Spring Security 的类:

<bean id="messageSource"
    class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
<property name="basename" value="classpath:messages"/>
</bean>

接下来只需要拷贝一份 messages_zh_CN.properties 配置文件到我们的 classpath 即 Maven 目录结构的 resources 下,修改错误提示信息即可。

然而上述配置在我的 Spring Boot 2.0.4.RELEASE 集成环境下并没有起到预期的效果!!!

在代码中注入上述配置的 messageSource bean,调用 getMessage(...) 方法确实能拿到我们自定义的错误提示信息,但是 Spring Security 返回给页面的提示仍然是坏的凭证……

解决方案

拷贝一份 messages_zh_CN.properties 配置文件到我们的 classpath 即 Maven 目录结构的 resources 下的 org/springframework/security 目录中,修改错误提示信息即可。

不需要配置 messageSource 的 bean,此方案直接暴力覆盖了 Spring Security 的配置文件,Spring Security 通过自己的默认配置加载的就是我们的 messages[_language_country].properties

题外话

百度 + 谷歌 + 官方手册 + Spring Security 源码,此问题耗费了我两个多小时的宝贵时间,而且我隐约记得之前也解决过这个问题。

好记性不如烂笔头,特此记下,利人利己,何乐而不为呢。

推荐阅读更多精彩内容