美文网首页apereo cas我爱编程
Apereo Cas 单点登陆 添加验证码

Apereo Cas 单点登陆 添加验证码

作者: 说你还是说我 | 来源:发表于2018-04-07 21:36 被阅读413次

基于apereo cas 5.2.3
参考文章
https://blog.csdn.net/u010475041/article/details/78572812
参考资料
https://apereo.github.io/cas/5.2.x/installation/Configuring-Custom-Authentication.html
https://apereo.github.io/cas/5.2.x/installation/Database-Authentication.html
需要掌握的资料spring boot 自动装配spring webflow

效果图


image.png

笔者这里的思路是拓展apereo cas的验证,提供自定义的验证方式。尽量做到不入侵cas源码,降低程序安全风险。

实现思路

  • 1.拓展 apereo cas UsernamePasswordCredential
    增加验证码属性
  • 2.自定义RememberMeUsernamePasswordCredential 类
    在使用记住账户的情况下使用
  • 3.拓展 apereo cas AbstractUsernamePasswordAuthenticationHandler
    自定义验证处理。apereo cas只支持继承这个类的自定义验证类,才会添加到验证处理类里面
  • 4.注入我们自定义的验证处理类
  • 5.在执行登陆提交前,执行自定义的webflow Action
    验证码验证

下面来通过代码展示

笔者这里是通过数据库的方式来验证的。

  • 实现自己的credetial实体,增加验证码字段captcha
public class CustUsernamePasswordCredential extends UsernamePasswordCredential {

    private static final long serialVersionUID = 1767227441947916650L;

    private String captcha;
    
    public CustUsernamePasswordCredential() {
    }

    public CustUsernamePasswordCredential(String userName, String password, String captcha) {
        super(userName, password);
        this.captcha = captcha;
    }

    public String getCaptcha() {
        return captcha;
    }

    public void setCaptcha(String captcha) {
        this.captcha = captcha;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof CustUsernamePasswordCredential)) return false;
        if (!super.equals(o)) return false;
        CustUsernamePasswordCredential that = (CustUsernamePasswordCredential) o;
        return Objects.equals(getCaptcha(), that.getCaptcha());
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), getCaptcha());
    }
}
  • 实现rememberMe实体
    实现这类的原因是因为我们实现了自己的验证处理方式,增加了captcha字段,原来的RememberMeUsernamePasswordCredential是继承默认的UsernamePasswordCredential,就没有captcha字段。所以我们自定义了一个rememberMe类。从而防止在启用rememberme的时候,丢失captcha数据。
public class CustRememberMeUsernamePasswordCredential extends CustUsernamePasswordCredential implements RememberMeCredential {

    /** Unique Id for serialization. */
    private static final long serialVersionUID = -6710007659431302397L;

    private boolean rememberMe;

    @Override
    public boolean isRememberMe() {
        return this.rememberMe;
    }

    @Override
    public int hashCode() {
        return new HashCodeBuilder()
                .appendSuper(super.hashCode())
                .append(this.rememberMe)
                .toHashCode();
    }

    @Override
    public boolean equals(final Object obj) {
        if (this == obj) {
            return true;
        }
        if (!super.equals(obj)) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final CustRememberMeUsernamePasswordCredential other = (CustRememberMeUsernamePasswordCredential) obj;
        return this.rememberMe == other.rememberMe;
    }

    @Override
    public void setRememberMe(final boolean rememberMe) {
        this.rememberMe = rememberMe;
    }
}
  • 拓展AbstractUsernamePasswordAuthenticationHandler类,自定义CustAbstractUsernamePasswordAuthenticationHandler抽象类
    因为我们自定义的credintial是继承UsernamePasswordCredential。所以在doAuthentication()方法里,我们转换credential为我们自定义的CustUsernamePasswordCredential ,但是在调用authenticateUsernamePasswordInternal()的时候默认的是UsernamePasswordCredential类。因为我们的CustUsernamePasswordCredential类是继承UsernamePasswordCredential,所以我们强制转换一下。
    authenticateUsernamePasswordInternal()这个方法是AbstractUsernamePasswordAuthenticationHandler的实现,我们不能修改参数。所以笔者又自定义了一个抽象方法custAuthenticateUsernamePasswordInternal(),在它的子类来具体的实现验证规则。
@Override
    protected HandlerResult authenticateUsernamePasswordInternal(UsernamePasswordCredential transformedCredential, String originalPassword) throws GeneralSecurityException, PreventedException {
        CustUsernamePasswordCredential custUsernamePasswordCredential = (CustUsernamePasswordCredential) transformedCredential;
        return this.custAuthenticateUsernamePasswordInternal(custUsernamePasswordCredential,originalPassword);
    }

    @Override
    protected HandlerResult doAuthentication(final Credential credential) throws GeneralSecurityException, PreventedException {

        final CustUsernamePasswordCredential originalUserPass = (CustUsernamePasswordCredential) credential;
        final CustUsernamePasswordCredential userPass = new CustUsernamePasswordCredential(originalUserPass.getUsername(),
                originalUserPass.getPassword(),originalUserPass.getCaptcha());

        if (StringUtils.isBlank(userPass.getCaptcha())) {
            LOGGER.error("验证码不能为空");
            throw new AccountNotFoundException("Captcha is null.");
        }

        if (StringUtils.isBlank(userPass.getUsername())) {
            throw new AccountNotFoundException("Username is null.");
        }

        LOGGER.debug("Transforming credential username via [{}]", this.principalNameTransformer.getClass().getName());
        final String transformedUsername = this.principalNameTransformer.transform(userPass.getUsername());
        if (StringUtils.isBlank(transformedUsername)) {
            throw new AccountNotFoundException("Transformed username is null.");
        }

        if (StringUtils.isBlank(userPass.getPassword())) {
            throw new FailedLoginException("Password is null.");
        }

        LOGGER.debug("Attempting to encode credential password via [{}] for [{}]", this.passwordEncoder.getClass().getName(), transformedUsername);
        final String transformedPsw = this.passwordEncoder.encode(userPass.getPassword());
        if (StringUtils.isBlank(transformedPsw)) {
            throw new AccountNotFoundException("Encoded password is null.");
        }

        userPass.setUsername(transformedUsername);
        userPass.setPassword(transformedPsw);

        LOGGER.debug("Attempting authentication internally for transformed credential [{}]", userPass);
        return authenticateUsernamePasswordInternal(userPass, originalUserPass.getPassword());
    }

    protected abstract HandlerResult custAuthenticateUsernamePasswordInternal(CustUsernamePasswordCredential transformedCredential, String originalPassword)
            throws GeneralSecurityException, PreventedException;
  • 添加jdbc验证支持
    在抽象类CustAbstractUsernamePasswordAuthenticationHandler的基础上实现jdbc验证支持,这里参考的是apereo cas 源码AbstractJdbcUsernamePasswordAuthenticationHandler类
public abstract class CustAbstractJdbcUsernamePasswordAuthenticationHandler extends CustAbstractUsernamePasswordAuthenticationHandler {

    private final JdbcTemplate jdbcTemplate;
    private final DataSource dataSource;

    public CustAbstractJdbcUsernamePasswordAuthenticationHandler(final String name, final ServicesManager servicesManager, final PrincipalFactory principalFactory,
                                                             final Integer order, final DataSource dataSource) {
        super(name, servicesManager, principalFactory, order);
        this.dataSource = dataSource;
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    /**
     * Method to return the jdbcTemplate.
     *
     * @return a fully created JdbcTemplate.
     */
    protected JdbcTemplate getJdbcTemplate() {
        return this.jdbcTemplate;
    }

    protected DataSource getDataSource() {
        return this.dataSource;
    }

}
  • 实现具体的验证处理类
    这里参考apereo cas源码QueryAndEncodeDatabaseAuthenticationHandler类
    在custAuthenticateUsernamePasswordInternal()方法里实现具体的验证
public class MyAuthenticationHandler extends CustAbstractJdbcUsernamePasswordAuthenticationHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger(MyAuthenticationHandler.class);

    private final String sql;
    private final String fieldPassword;
    private final String fieldExpired;
    private final String fieldDisabled;
    private final Map<String, Collection<String>> principalAttributeMap;

    public MyAuthenticationHandler(final String name, final ServicesManager servicesManager,
                                              final PrincipalFactory principalFactory,
                                              final Integer order, final DataSource dataSource, final String sql,
                                              final String fieldPassword, final String fieldExpired, final String fieldDisabled,
                                              final Map<String, Collection<String>> attributes) {
        super(name, servicesManager, principalFactory, order, dataSource);
        this.sql = sql;
        this.fieldPassword = fieldPassword;
        this.fieldExpired = fieldExpired;
        this.fieldDisabled = fieldDisabled;
        this.principalAttributeMap = attributes;
    }

    @Override
    protected HandlerResult custAuthenticateUsernamePasswordInternal(final CustUsernamePasswordCredential credential, final String originalPassword)
            throws GeneralSecurityException, PreventedException {

        if (StringUtils.isBlank(this.sql) || getJdbcTemplate() == null) {
            throw new GeneralSecurityException("Authentication handler is not configured correctly. "
                    + "No SQL statement or JDBC template is found.");
        }

        final Map<String, Object> attributes = new LinkedHashMap<>(this.principalAttributeMap.size());
        final String username = credential.getUsername();
        final String password = credential.getPassword();
        try {
            final Map<String, Object> dbFields = getJdbcTemplate().queryForMap(this.sql, username);
            final String dbPassword = (String) dbFields.get(this.fieldPassword);

            if (StringUtils.isNotBlank(originalPassword) && !matches(originalPassword, dbPassword)
                    || StringUtils.isBlank(originalPassword) && !StringUtils.equals(password, dbPassword)) {
                throw new FailedLoginException("Password does not match value on record.");
            }
            if (StringUtils.isNotBlank(this.fieldDisabled)) {
                final Object dbDisabled = dbFields.get(this.fieldDisabled);
                if (dbDisabled != null && (Boolean.TRUE.equals(BooleanUtils.toBoolean(dbDisabled.toString())) || dbDisabled.equals(Integer.valueOf(1)))) {
                    throw new AccountDisabledException("Account has been disabled");
                }
            }
            if (StringUtils.isNotBlank(this.fieldExpired)) {
                final Object dbExpired = dbFields.get(this.fieldExpired);
                if (dbExpired != null && (Boolean.TRUE.equals(BooleanUtils.toBoolean(dbExpired.toString())) || dbExpired.equals(1))) {
                    throw new AccountPasswordMustChangeException("Password has expired");
                }
            }
            this.principalAttributeMap.forEach((key, attributeNames) -> {
                final Object attribute = dbFields.get(key);

                if (attribute != null) {
                    LOGGER.debug("Found attribute [{}] from the query results", key);
                    attributeNames.forEach(s -> {
                        LOGGER.debug("Principal attribute [{}] is virtually remapped/renamed to [{}]", key, s);
                        attributes.put(s, CollectionUtils.wrap(attribute.toString()));
                    });
                } else {
                    LOGGER.warn("Requested attribute [{}] could not be found in the query results", key);
                }

            });

        } catch (final IncorrectResultSizeDataAccessException e) {
            if (e.getActualSize() == 0) {
                throw new AccountNotFoundException(username + " not found with SQL query");
            }
            throw new FailedLoginException("Multiple records found for " + username);
        } catch (final DataAccessException e) {
            throw new PreventedException("SQL exception while executing query for " + username, e);
        }
        return createHandlerResult(credential, this.principalFactory.createPrincipal(username, attributes), null);
    }

}
  • 添加验证码验证action
public class CaptchaAction extends AbstractAction {

    private static final Logger LOGGER = LoggerFactory.getLogger(CaptchaAction.class);

    public static final String CAPTCHA_KEY = "captcha_key";

    //验证码异常
    private static final String CUSTOM_CAPTCHA_FAIL_EXCEPTION = "custom.captcha.fail.expection";
    //验证码验证失败
    private static final String CUSTOM_CAPTCHA_FAIL_ERROR = "custom.captcha.fail.error";

    @Override
    protected Event doExecute(RequestContext context) throws Exception {
        HttpServletResponse response = (HttpServletResponse) context.getExternalContext().getNativeResponse();
        HttpServletRequest request = (HttpServletRequest) context.getExternalContext().getNativeRequest();
        HttpSession session = request.getSession();
        String captchaKey = (String) session.getAttribute(CAPTCHA_KEY);
        if(null == captchaKey){
            LOGGER.error("验证码验证异常");
            return this.getError(context,CUSTOM_CAPTCHA_FAIL_EXCEPTION);
        }

        CustUsernamePasswordCredential credential = (CustUsernamePasswordCredential) WebUtils.getCredential(context);
        if(null == credential || (null == credential.getCaptcha() || "".equals(credential.getCaptcha().trim()))){
            LOGGER.error("请输入验证码");
            return this.getError(context,CUSTOM_CAPTCHA_FAIL_ERROR);

        }

        String requestCaptcha = credential.getCaptcha();
        if(!captchaKey.toLowerCase().equals(requestCaptcha.toLowerCase())){
            LOGGER.error("验证码错误");
            return this.getError(context,CUSTOM_CAPTCHA_FAIL_ERROR);
        }

        return super.success();
    }

    /**
     * 跳转到错误页
     * @param requestContext
     * @return
     */
    private Event getError(final RequestContext requestContext,String msg) {
        final MessageContext messageContext = requestContext.getMessageContext();
        messageContext.addMessage(new MessageBuilder().error().code(msg).build());
        return getEventFactorySupport().event(this, msg);
    }

}
  • 重写登陆提交的model
    参考apereo cas源码DefaultWebflowConfigurer类。重写DefaultWebflowConfigurerz中的createRememberMeAuthnWebflowConfig()方法,让cas可以解析到captcha字段。
public class CustDefaultWebflowConfigurer extends DefaultWebflowConfigurer {

    /**
     * Instantiates a new Default webflow configurer.
     *
     * @param flowBuilderServices    the flow builder services
     * @param flowDefinitionRegistry the flow definition registry
     * @param applicationContext     the application context
     * @param casProperties          the cas properties
     */
    public CustDefaultWebflowConfigurer(final FlowBuilderServices flowBuilderServices,
                                    final FlowDefinitionRegistry flowDefinitionRegistry,
                                    final ApplicationContext applicationContext,
                                    final CasConfigurationProperties casProperties) {
        super(flowBuilderServices, flowDefinitionRegistry, applicationContext, casProperties);
    }

    /**
     * 重写 {@link org.apereo.cas.web.flow.configurer.DefaultWebflowConfigurer} 的方法
     *
     * Create remember me authn webflow config.
     *
     * @param flow the flow
     */
    protected void createRememberMeAuthnWebflowConfig(final Flow flow) {
        //判断是否是启用rememberMe功能
        if (casProperties.getTicket().getTgt().getRememberMe().isEnabled()) {
            /**
             *用我们拓展CustUsernamePasswordCredential的CustRememberMeUsernamePasswordCredential来替换原来的
             * {@link org.apereo.cas.authentication.RememberMeUsernamePasswordCredential}
             */
            createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, CustRememberMeUsernamePasswordCredential.class);
            final ViewState state = getState(flow, CasWebflowConstants.STATE_ID_VIEW_LOGIN_FORM, ViewState.class);
            final BinderConfiguration cfg = getViewStateBinderConfiguration(state);
            cfg.addBinding(new BinderConfiguration.Binding("rememberMe", null, false));
        } else {
            /**
             *用我们拓展CustUsernamePasswordCredential来替换原来的
             * {@link org.apereo.cas.authentication.UsernamePasswordCredential}
             */
            createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, CustUsernamePasswordCredential.class);
        }
    }
}
  • 注入我们自定义的spring boot自动装配类MyAuthenticationEventExecutionPlanConfiguration
    captchaController()注入验证码验提共类,一个controller用来提供验证码生成
    captchaAction()注入验证码验证action,这需要了解spring webflow
    defaultWebflowConfigurer()注入自定义webflow登陆配置
    myAuthenticationHandler()注入自定义验证处理类
    @Bean
    public CaptchaController captchaController(){
        return new CaptchaController();
    }

    @Bean
    public CaptchaAction captchaAction(){
        return new CaptchaAction();
    }

    @Bean
    @Order(1)
    public CasWebflowConfigurer defaultWebflowConfigurer() {
        final CustDefaultWebflowConfigurer c = new CustDefaultWebflowConfigurer(builder(), loginFlowRegistry(), applicationContext, casProperties);
        c.setLogoutFlowDefinitionRegistry(logoutFlowRegistry());
        c.initialize();
        return c;
    }

    @Bean
    public AuthenticationHandler myAuthenticationHandler() {
        final JdbcAuthenticationProperties jdbc = casProperties.getAuthn().getJdbc();
        QueryJdbcAuthenticationProperties b = jdbc.getQuery().get(0);
        final Multimap<String, String> attributes = CoreAuthenticationUtils.transformPrincipalAttributesListIntoMultiMap(b.getPrincipalAttributeList());
        final MyAuthenticationHandler h = new MyAuthenticationHandler(b.getName()+"_amazing", servicesManager,
                new DefaultPrincipalFactory(), b.getOrder(),
                JpaBeans.newDataSource(b), b.getSql(), b.getFieldPassword(),
                b.getFieldExpired(), b.getFieldDisabled(),
                CollectionUtils.wrap(attributes));

        h.setPasswordEncoder(PasswordEncoderUtils.newPasswordEncoder(b.getPasswordEncoder()));
        h.setPrincipalNameTransformer(PrincipalNameTransformerUtils.newPrincipalNameTransformer(b.getPrincipalTransformation()));
        if (queryPasswordPolicyConfiguration != null) {
            h.setPasswordPolicyConfiguration(queryPasswordPolicyConfiguration);
        }

        h.setPrincipalNameTransformer(PrincipalNameTransformerUtils.newPrincipalNameTransformer(b.getPrincipalTransformation()));

        if (StringUtils.isNotBlank(b.getCredentialCriteria())) {
            h.setCredentialSelectionPredicate(CoreAuthenticationUtils.newCredentialSelectionPredicate(b.getCredentialCriteria()));
        }

        LOGGER.debug("Created authentication handler [{}] to handle database url at [{}]", h.getName(), b.getUrl());
        return h;
    }

还有两个类CaptchaController和CodeDrawUtil(验证码生成工具),这里就不一一例举了。后面会放到码云上面。

  • 修改login-webflow.xml
    添加验证码验证action,<evaluate expression="captchaAction"/>。
<view-state id="viewLoginForm" view="casLoginView" model="credential">
        <binder>
            <binding property="username" required="true"/>
            <binding property="password" required="true"/>
            <binding property="captcha" required="true"/>
        </binder>
        <transition on="submit" bind="true" validate="true" to="realSubmit" history="invalidate" >
            <evaluate expression="captchaAction"/>
        </transition>
    </view-state>
  • 修改loginform.xml
    添加验证码字段
<section class="row">
            <label for="captcha" th:utext="#{screen.welcome.label.captcha}"/>

            <div>
                <input class="required"
                       type="text"
                       id="captcha"
                       name="captcha"
                       size="25"
                       tabindex="2"
                       autocomplete="off"/>
                <img th:src="@{/captcha/get}"/>
            </div>
        </section>
  • 修改application.properties文件
#配置数据库
#按照用户名来查找数据
cas.authn.jdbc.query[0].sql=SELECT * FROM xxx WHERE username=?
cas.authn.jdbc.query[0].url=jdbc:mysql://192.168.190.129:3306/xxx
cas.authn.jdbc.query[0].dialect=org.hibernate.dialect.MySQL57InnoDBDialect
cas.authn.jdbc.query[0].user=root
cas.authn.jdbc.query[0].password=xxx
cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver
#需要匹配的数据库密码字段
cas.authn.jdbc.query[0].fieldPassword=xxx

logging.level.org.apereo=DEBUG
#清空默认匹配的用户
cas.authn.accept.users=
#密码加密算法
cas.authn.jdbc.query[0].passwordEncoder.type=BCRYPT
cas.authn.jdbc.query[0].passwordEncoder.characterEncoding=UTF-8
  • 国际化文件添加验证码提示内容
#custom message
custom.captcha.fail.exception=\u9a8c\u8bc1\u7801\u9a8c\u8bc1\u5f02\u5e38
custom.captcha.fail.error=\u9a8c\u8bc1\u7801\u9519\u8bef
screen.welcome.label.captcha=\u9a8c\u8bc1\u7801
  • 添加自动装配文件
    在META-INF的spring.factories文件中添加需要装配的类
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.cas.configuration.MyAuthenticationEventExecutionPlanConfiguration

码云地址: cas-overlay-template,下载下来需要按照readme里面的步骤来构建项目。

最后,这里总的来说是对于技术的探讨。笔者认为单点登陆只是一个统一认证的服务,添加验证码只是为了防止恶意的访问。

相关文章

网友评论

    本文标题:Apereo Cas 单点登陆 添加验证码

    本文链接:https://www.haomeiwen.com/subject/izwyhftx.html