Skip to content

Instantly share code, notes, and snippets.

@Kambaa
Last active July 5, 2024 19:14
Show Gist options
  • Save Kambaa/ba276643175e2c88666cce823d691d68 to your computer and use it in GitHub Desktop.
Save Kambaa/ba276643175e2c88666cce823d691d68 to your computer and use it in GitHub Desktop.
My CAS 6.6.x Setup Notes

My notes on CAS server installation when learning it. Hope it will be helpful to somebody.

Initial/Basic setup.

  • Go to https://getcas.apereo.org/ui and download an overlay template project. You can use this example settings applied url (select version field 6.6.x if empty)

  • Generate a keystore and put it to /etc/cas/keystore(on Linux) or C:\etc\cas\keystore(on Windows):

    You can do this two ways:

    1. Automatic generation using the Gradle task defined in the project:

    In the project, there's a gradle task that does this automatically, but the problem is, machine you're developing this on could be protected, so you need to run the command below as an "administrator"

     ./gradlew createKeystore

    2. Manually: You can generate a keystore manually via the command below. Keystore password should be changeit

    keytool -genkey -keyalg RSA -alias thekeystore -keystore thekeystore -storepass changeit -validity 360 -keysize 2048

    Enter the asked fields as you see fit and put the generated keystore to etc/cas/thekeystore.

  • Prepare configurations for project run to /etc/cas/(on Linux) or C:\etc\cas\(onWindows)

    Again you can do this automatically or manually. Automatic gradle operation task can be called(as an "administrator") like:

     ./gradlew copyCasConfiguration

    Or you can just copy the etc/cas/config on the project to your system's /etc/cas/config(On Linux) or C:\etc\cas\config(On Windows)

  • Run the gradle wrapper command below to start basic cas server:

./gradlew run 
Username: casuser 
Password: Mellon
  • Check that you have successfully logged in.

  • Congrats! You have successfully entered the CAS World!

Dev env problems and Idea configs:

This worked for Linux, did not work for Windows:

Add -DbaseDir=C:\\dev\\logs to shut the log4j2 initial error! it made me mad!:) Also debugging is a nightmare, always a zombie gradle process(something about the gradle deamon, IDK!) stays and if you want to restart the app, you need to kill these gradle zombie processes ( java.exe ....) i added some vm args to the debug gradle task to work, if you run the project via idea, use these vm args:

JVM args for less headache:

  • -Dorg.gradle.daemon=false this disables the daemon for the current gradle task run
  • -DbaseDir=C:\\dev\\logs this makes sure the use a correct path for the log4j2

also, when starting the run configuration, check out the logs and make sure to click to Attach debugger link appears in the console to idea to start debugging the app. After clicking project will continue running its startup sequence. After that smooth sailing(with zombie processes of course!). For idea, i did define a run configuration of type Remote JVM debug, and set it up that before Remote JVM Debug launch, run the debug gradle task.

./gradlew run -DbaseDir=C:\\dev\\logs -Dorg.gradle.daemon=false

Configs on Db:

Default behaviour is setup on the projects etc/ dir and use the gradle script to copy to /etc or C: \etc before running the app. To persist settings to db and use it on startup:

First add the dependency on build.gradle file under dependencies:

implementation "org.apereo.cas:cas-server-support-configuration-cloud-jdbc"

And then, generate The Tables On Db(below is postgres):

create table if not exists CAS_SETTINGS_TABLE
(
    ID          serial
        constraint cas_settings_pk primary key,
    NAME        TEXT UNIQUE                       NOT NULL,
    VALUE       TEXT                              NOT NULL,
    DESCRIPTION TEXT DEFAULT ('açıklama giriniz') NOT NULL
);

Check and get the values from cas startup logs that you have run before or read the Cas docs to learn how to generate and get each of them.

INSERT INTO cas_settings_table (name, value, description)
VALUES ('cas.tgc.crypto.encryption.key', '<SOMEKEY>',
        'Generated encryption key [] of size [256] for [Ticket-granting Cookie]. The generated key MUST be added to CAS settings');
INSERT INTO cas_settings_table (name, value, description)
VALUES ('cas.tgc.crypto.signing.key', '<SOMEKEY>', 'Generated signing key [] of size [512] for [Ticket-granting Cookie]. The generated key MUST be added to CAS settings:
');
INSERT INTO cas_settings_table (name, value, description)
VALUES ('cas.webflow.crypto.signing.key', '<SOMEKEY>',
        'Generated signing key [] of size [512]. The generated key MUST be added to CAS settings:');
INSERT INTO cas_settings_table (name, value, description)
VALUES ('cas.webflow.crypto.encryption.key', '<SOMEKEY>',
        'Generated encryption key [] of size [16]. The generated key MUST be added to CAS settings:');
INSERT INTO cas_settings_table (name, value, description)
VALUES ('cas.authn.accept.enabled', 'true',
        'CAS is configured to accept a static list of credentials for authentication. While this is generally useful for demo purposes, it is STRONGLY recommended that you DISABLE this authentication method by setting ''cas.authn.accept.enabled=false'' and switch to a mode that is more suitable for production.');
INSERT INTO cas_settings_table (name, value, description)
VALUES ('spring.security.user.name', 'demo', 'Spring security secured username');
INSERT INTO cas_settings_table (name, value, description)
VALUES ('spring.security.user.password', 'demo', 'Spring security secured password');

After successful insertion, open the file <PROJECT_DIR>src/main/resources/application.yml and add these db connection information:

# Cas ayarlarının veritabanında alınmasını sağlamak adına aşağıdaki ayarları yapıyoruz.
cas:
  spring:
    cloud:
      jdbc:
        driver-class: org.postgresql.Driver
        url: jdbc:postgresql://localhost:5432/<DBNAME>
        user: <USERNAME>
        password: <PASSWORD>

With the inserted rows, some warnings about key generation will not come again.

Setting up logging configuration:

Project's default behaviour is to use the etc/cas/config/log4j2.xml file at the project root, if you copy this to /etc/cas/config or C:/etc/cas/config, it will override the config.

To use a config file at the project's classpath(src/main/resources), simply add a configuration to the application.yml:

logging:
  config: 'classpath:config/logging/log4j2.xml'

you can modify default configurations according to your needs.

Use Database Authentication:

Project's default behaviour is a static user list, to disable it and authenticate users from a db :

  • For example we have user table this(on postgres):

    CREATE TABLE users
    (
        id         bigint NOT NULL,
        disabled   boolean,
        email      character varying(40) COLLATE pg_catalog."default",
        first_name character varying(40) COLLATE pg_catalog."default",
        last_name  character varying(40) COLLATE pg_catalog."default",
        expired    boolean,
        password   character varying(100) COLLATE pg_catalog."default",
        CONSTRAINT users_pkey PRIMARY KEY (id),
        CONSTRAINT users_unique_email UNIQUE (email)
    );
    INSERT INTO users(id, disabled, email, first_name, last_name, expired, password)
    VALUES (1, false, 'test@test.com', 'test', 'user1', false,
            'wasd');
  • First add the necessary dependency to build.gradle:

    implementation "org.apereo.cas:cas-server-support-jdbc"
    implementation "org.apereo.cas:cas-server-support-jdbc-drivers"
    
    
  • Next, add the necessary user checking configurations to your settings. Below is written for a java properties file, but for this project it is in the cas_settings_table and these settings need to be put there.

    # Authenticates a user by comparing the user password (which can be encoded with a password encoder) against the password on record determined by a configurable database query.
    # https://apereo.github.io/cas/6.6.x/authentication/Database-Authentication.html#query-database-authentication
    # Required Settings
    cas.authn.jdbc.query[0].driver-class=org.postgresql.Driver
    cas.authn.jdbc.query[0].url=jdbc:postgresql://localhost:5432/postgres
    cas.authn.jdbc.query[0].dialect=org.hibernate.dialect.PostgreSQL95Dialect
    cas.authn.jdbc.query[0].user=postgres
    cas.authn.jdbc.query[0].password=postgres
    cas.authn.jdbc.query[0].sql=SELECT * FROM users WHERE email = ?
    cas.authn.jdbc.query[0].field-password=password
    cas.authn.jdbc.query[0].password-encoder.type=NONE
    # Optional Settings
    cas.authn.jdbc.query[0].field-expired=expired
    cas.authn.jdbc.query[0].field-disabled=disabled

    Örnek sql insert scripti:

    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].driver-class', 'org.postgresql.Driver',
            'Kullanıcıların bakılacağı veritabanı JDBC bağlantı driver');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].dialect', 'org.hibernate.dialect.PostgreSQL95Dialect',
            'Kullanıcıların bakılacağı veritabanı JDBC bağlantı SQL dialect');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].url', 'jdbc:postgresql://localhost:5432/postgres',
            'Kullanıcıların bakılacağı veritabanı JDBC bağlantı URL');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].user', 'postgres',
            'Kullanıcıların bakılacağı veritabanı JDBC bağlantı kullanıcı adı');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].password', 'postgres',
            'Kullanıcıların bakılacağı veritabanı JDBC bağlantı şifresi');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].sql', 'SELECT * FROM users WHERE email = ?',
            'Kullanıcıların bakılacağı veritabanı sorgu sql');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].field-password', 'password',
            'Kullanıcıların bakılacağı veritabanı tablosundaki karşılaştırılacak şifrelerin bulunduğu kolon');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].password-encoder.type', 'NONE',
            'Kullanıcıların bakılacağı veritabanı tablosu şifre kolonundaki değerlerin şifreleme bilgisi');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].field-expired', 'expired',
            'Kullanıcıların bakılacağı veritabanı tablosunda, kullanıcının zaman aşımı(expired) olduğunu belirten kolon adı');
    INSERT INTO cas_settings_table (name, value, description)
    VALUES ('cas.authn.jdbc.query[0].field-disabled', 'disabled',
            'Kullanıcıların bakılacağı veritabanı tablosunda, kullanıcının devre dışı(disabled) olduğunu belirten kolon adı');
  • And lastly, restart your application via these commands: Reload dependencies and rebuild the project.

    ./gradlew clean build --refresh-dependencies  

    Run the project.

    ./gradlew run  

    When you go to to https://localhost:8443/cas/login and enter the credentials you've entered to the users table before, you should successfully be authenticated.

    WARNING:

    User passwords are clearly visible on the database for this example(check out the config key cas.authn.jdbc.query[0].password-encoder.type is set to NONE), you should read and set up some kind of one-way encryption. Details are here.

Enable JDBC Audit Logs:

To enable audit logs, first add the necessary dependency in build.gradle:

implementation "org.apereo.cas:cas-server-support-audit-jdbc"

After that, configure the properties accordingly descibed here, for this example you can use the basic setup below:

cas.audit.engine.app-code=CAS
cas.audit.engine.excluded-actions=AUTHENTICATION_SUCCESS, AUTHENTICATION_EVENT_TRIGGERED,SERVICE_ACCESS_ENFORCEMENT_TRIGGERED
cas.audit.jdbc.date-formatter-pattern=dd-MM-yyyy
cas.audit.jdbc.ddl-auto=update
cas.audit.jdbc.dialect=org.hibernate.dialect.PostgreSQL95Dialect
cas.audit.jdbc.driver-class=org.postgresql.Driver
cas.audit.jdbc.max-age-days=30
cas.audit.jdbc.password=postgres
cas.audit.jdbc.select-sql-query-template=SELECT * FROM %s WHERE  AUD_DATE>=TO_DATE('%s','dd-MM-yyyy') ORDER BY AUD_DATE DESC
cas.audit.jdbc.url=jdbc:postgresql://localhost:5432/postgres
cas.audit.jdbc.user=postgres

For this use case, here's the sql version:

INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.engine.app-code', 'CAS');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.engine.excluded-actions',
        'AUTHENTICATION_SUCCESS, AUTHENTICATION_EVENT_TRIGGERED,SERVICE_ACCESS_ENFORCEMENT_TRIGGERED');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.date-formatter-pattern', 'dd-MM-yyyy');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.ddl-auto', 'update');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.dialect', 'org.hibernate.dialect.PostgreSQL95Dialect');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.driver-class', 'org.postgresql.Driver');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.max-age-days', '30');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.password', 'postgres');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.select-sql-query-template',
        'SELECT * FROM %s WHERE  AUD_DATE>=TO_DATE(''%s'',''dd-MM-yyyy'') ORDER BY AUD_DATE DESC');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.url', 'jdbc:postgresql://localhost:5432/postgres');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.audit.jdbc.user', 'postgres');

Using Hazelcast Ticket Registry:

To use Hazelcast for ticket stroge, add the necessary dependency first:

implementation "org.apereo.cas:cas-server-support-hazelcast-ticket-registry"

after that configure accordingly. (link)

cas.ticket.registry.hazelcast.cluster.core.instance-name=localhost
cas.ticket.registry.hazelcast.cluster.network.members=my.example.com.tr
cas.ticket.registry.hazelcast.cluster.network.port=5701
cas.ticket.registry.hazelcast.page-size=500
cas.ticket.st.number-of-uses=1
cas.ticket.st.time-to-kill-in-seconds=30
cas.ticket.tgt.core.onlyTrackMostRecentSession=false
cas.ticket.tgt.primary.max-time-to-live-in-seconds=28800
cas.ticket.tgt.primary.time-to-kill-in-seconds=3600
# Below Settings Are Fro Kubernetes Cluster Discovery:
# https://apereo.github.io/cas/6.6.x/ticketing/Hazelcast-Ticket-Registry-AutoDiscovery-Kubernetes.html
# cas.ticket.registry.hazelcast.cluster.discovery.enabled=true
# cas.ticket.registry.hazelcast.cluster.discovery.kubernetes.namespace=qa-cas
# cas.ticket.registry.hazelcast.cluster.discovery.kubernetes.service-name=cas-hz
# cas.ticket.registry.hazelcast.cluster.discovery.kubernetes.service-port=5701

and if your configuration is on db, add these configs as new rows on CAS_SETTINGS_TABLE.

INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.registry.hazelcast.cluster.core.instance-name', 'localhost');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.registry.hazelcast.cluster.network.members', 'my.example.com.tr');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.registry.hazelcast.cluster.network.port', '5701');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.registry.hazelcast.page-size', '500');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.st.number-of-uses', '1');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.st.time-to-kill-in-seconds', '30');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.tgt.core.onlyTrackMostRecentSession', 'false');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.tgt.primary.max-time-to-live-in-seconds', '28800');
INSERT INTO cas_settings_table (name, value)
VALUES ('cas.ticket.tgt.primary.time-to-kill-in-seconds', '3600');

Add custom authentication handler

Generallyt, we don't need to do this, but for a customized security configurations, this is good topic to be known. We can write our customized authentication handler, and register it to CAS to use it as explained here and here. Problem is, these explanations does not cover detailed operations like connecting to database or using the fields, etc. So we need to dig deeper, and learn to utilize already defined cas mechanisms to ease our problems.

  • First remove any Database authentication configurations defined before(like configuration entries starting with cas.authn.jdbc.query[0] ).

  • Add the necessary dependencies:

      implementation "org.apereo.cas:cas-server-core-authentication-api"
    
  • After that we start to design the authentication handling operations via writing a class that extends theAbstractUsernamePasswordAuthenticationHandler. For example:

  package tr.com.example.cas.config.auth.handler;
  
  import java.security.GeneralSecurityException;
  import lombok.extern.slf4j.Slf4j;
  import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult;
  import org.apereo.cas.authentication.PreventedException;
  import org.apereo.cas.authentication.credential.UsernamePasswordCredential;
  import org.apereo.cas.authentication.handler.support.AbstractUsernamePasswordAuthenticationHandler;
  import org.apereo.cas.authentication.principal.PrincipalFactory;
  import org.apereo.cas.services.ServicesManager;
  
  @Slf4j
  public class DemoAuthHandler extends AbstractUsernamePasswordAuthenticationHandler {
    protected DemoAuthHandler(String name, ServicesManager servicesManager,
                              PrincipalFactory principalFactory,
                              Integer order) {
      super(name, servicesManager, principalFactory, order);
    }
  
    @Override
    protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal(
        UsernamePasswordCredential credential, String originalPassword)
        throws GeneralSecurityException, PreventedException {
      // todo: implement your authentication logic here.
      return null;
    }
  }

Example on the links i've written before are very basic and just gives the developer the starting point. Even this example is like leaving a person on a desert, alone! :) So how about a working example huh? For this example use case, i have written this handler below, that check given credentials according to the User db table i've mentioned before, checks if user exist, validates the password if user found, and checks the user if its expired or disabled:

Gradle CAS dependencies for the example to work.

implementation "org.apereo.cas:cas-server-core-authentication-api"
implementation "org.apereo.cas:cas-server-core-util-api"
implementation "org.apereo.cas:cas-server-support-jpa-util"
implementation "org.apereo.cas:cas-server-support-jdbc-authentication"
package tr.com.example.cas.config.auth.handler;

import com.google.common.collect.Maps;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import javax.security.auth.login.AccountNotFoundException;
import javax.security.auth.login.FailedLoginException;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult;
import org.apereo.cas.authentication.PreventedException;
import org.apereo.cas.authentication.credential.UsernamePasswordCredential;
import org.apereo.cas.authentication.exceptions.AccountDisabledException;
import org.apereo.cas.authentication.exceptions.AccountPasswordMustChangeException;
import org.apereo.cas.authentication.handler.support.AbstractUsernamePasswordAuthenticationHandler;
import org.apereo.cas.authentication.principal.PrincipalFactory;
import org.apereo.cas.configuration.support.JpaBeans;
import org.apereo.cas.services.ServicesManager;
import org.apereo.cas.util.CollectionUtils;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.apereo.cas.adaptors.jdbc.AbstractJdbcUsernamePasswordAuthenticationHandler;
import tr.com.example.cas.config.password.encoder.CustomPasswordEncoder;

/**
 * <p>
 * Normalde tutorialler AbstractUsernamePasswordAuthenticationHandler sınıfından türetin diyor
 * ancak, yapılacak db kontrolleri konusunda herşeyi developer'a bırakıyor. Bıraz karıştırarak,
 * CAS'ın db bağlantı ayarlamalarını yapmasını sağlamak amacıyla
 * AbstractJdbcUsernamePasswordAuthenticationHandler sınıfından türettim. Bu şekilde, jdbc
 * authentication'daki config yapısına benzer şekilde, db bağlantı ayarlarını CAS'ın yoğurt
 * yiyişinde alıp bağlantı sağlanmasını CAS'ın içinde bıraktım.
 *
 * @author Kambaa
 */
@Slf4j
public class CustomAuthenticationHandler extends AbstractJdbcUsernamePasswordAuthenticationHandler {

  // Custom configuration for this custom auth handler to work.
  private final CustomAuthenticationProperties properties;

  // Custom password endcoder for this custom auth handler to work.
  private final CustomPasswordEncoder passwordEncoder;

  // Constructor.
  public CustomAuthenticationHandler(
      final CustomAuthenticationProperties properties,
      final ServicesManager servicesManager,
      final PrincipalFactory principalFactory,
      CustomPasswordEncoder passwordEncoder
  ) {
    super(properties.getName(), servicesManager, principalFactory, properties.getOrder(),
        JpaBeans.newDataSource(properties));
    this.properties = properties;
    this.passwordEncoder = passwordEncoder;
  }

  // Custom authentication method.
  @Override
  protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal(
      final UsernamePasswordCredential credential,
      final String originalPassword)
      throws GeneralSecurityException, PreventedException {
    
    // check the sql configuration of this custom auth handler
    if (StringUtils.isBlank(properties.getSql()) || getJdbcTemplate() == null) {
      throw new GeneralSecurityException("Authentication handler is not configured correctly");
    }

    val attributes =
        Maps.<String, List<Object>>newHashMap();

    val username = credential.getUsername();
    try {
      
      val values = performSqlQuery(username);
      
      // use the custom encoder to encode the password(or salt+password, or custom data+password, if you catch my drift :) CAS can do password+salt internally with given config, so write your custom handlers if you need absolutely necessary)
      val encoded = passwordEncoder.encode(credential.getPassword());
      
      // check password
      if (!values.get(properties.getPasswordFieldName()).equals(encoded)) {
        throw new FailedLoginException("Password does not match value on record.");
      }

      // check user is expired
      if (StringUtils.isNotBlank(properties.getExpiredFieldName()) && values.containsKey(
          properties.getExpiredFieldName())) {
        val dbExpired = values.get(properties.getExpiredFieldName()).toString();
        if (BooleanUtils.toBoolean(dbExpired) || "1".equals(dbExpired)) {
          throw new AccountPasswordMustChangeException("Password has expired");
        }
      }

      // check user is disabled
      if (StringUtils.isNotBlank(properties.getDisabledFieldName()) && values.containsKey(
          properties.getDisabledFieldName())) {
        val dbDisabled = values.get(properties.getDisabledFieldName()).toString();
        if (BooleanUtils.toBoolean(dbDisabled) || "1".equals(dbDisabled)) {
          throw new AccountDisabledException("Account has been disabled");
        }
      }

      // save the user data in db to User Attributes(OPTIONAL and NOT ADVISED, for this example project only)
      values.forEach((key, names) -> {
        attributes.put(key, List.of(CollectionUtils.wrap(names)));
      });

      return createHandlerResult(credential,
          this.principalFactory.createPrincipal(username, attributes), new ArrayList<>(0));

    } 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(e);
    }
  }

  // Gets the necessary user information for custom auth from User db table.
  protected Map<String, Object> performSqlQuery(final String username) {
    return getJdbcTemplate().queryForMap(properties.getSql(), username);
  }

}

I reused and extended the AbstractJpaProperties properties class to use set up configuration in CAS's way and utilize the db operations of CAS internals:

package tr.com.example.cas.config.auth.handler;

import com.fasterxml.jackson.annotation.JsonFilter;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.apereo.cas.configuration.model.support.jdbc.authn.BaseJdbcAuthenticationProperties;
import org.apereo.cas.configuration.model.support.jpa.AbstractJpaProperties;
import org.apereo.cas.configuration.support.RequiredProperty;
import org.apereo.cas.configuration.support.RequiresModule;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;

/**
 * This is {@link CustomAuthenticationProperties}.
 *
 * @author Kambaa
 */
@RequiresModule(name = "cas-server-support-jdbc-authentication")
@Getter
@Setter
@Accessors(chain = true)
@JsonFilter("CustomAuthenticationProperties")
@ConfigurationProperties(value = "custom")
@RefreshScope
public class CustomAuthenticationProperties extends AbstractJpaProperties {

  private static final long serialVersionUID = 123456L;

  /**
   * SQL query to execute and look up accounts. Example:
   * {@code SELECT * FROM table WHERE username=?}.
   */
  @RequiredProperty
  private String sql;

  /**
   * Password column name.
   */
  private String passwordFieldName = "password";

  /**
   * Field/column name that indicates the username.
   */
  @RequiredProperty
  private String usernameFieldName = "email";

  /**
   * Column name that indicates whether account is expired.
   */
  private String expiredFieldName;

  /**
   * Column name that indicates whether account is disabled.
   */
  private String disabledFieldName;

  private String name = "CUSTOM-AUTHENTICATION";

  /**
   * Order of the authentication handler in the chain.
   */
  private int order = Integer.MAX_VALUE;

}

And here's a very basic CustomPasswordEncoder that does nothing, no-encoding, just returns it :).

package tr.com.example.cas.password.encoder;

import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * Custom password encoder class.
 * <p>
 * For CAS to use this encoder on db authentication, check the configuration:<br/>
 * <em>"cas.authn.jdbc.query[0].password-encoder.type"</em><br/>
 * on <em>"cas_settings_table"</em>
 * <p>
 *
 * @author Kambaa
 */
public class CustomPasswordEncoder implements PasswordEncoder {
    @Override
    public String encode(CharSequence charSequence) {
      return charSequence.toString();
    }

    @Override
    public boolean matches(CharSequence charSequence, String s) {
      return encode(charSequence).equals(s);
    }
}

After that it's time to enter configs like this(if you set up db config like written above, enter these there):

custom.driver-class=org.postgresql.Driver
custom.dialect=org.hibernate.dialect.PostgreSQL95Dialect
custom.url=jdbc:postgresql://localhost:5432/postgres
custom.user=postgres
custom.password=postgres
custom.passwordFieldName=password
custom.sql=SELECT * FROM users WHERE email = ?
custom.disabledFieldName=disabled
custom.expiredFieldName=expired

this is the design phase of custom authentication building. Now comes the registration part. Registering the authentication handler: To register the handler we'we written, we write a spring confgiguration class that extends AuthenticationEventExecutionPlanConfigurer

package tr.com.example.cas.config.auth.handler;

import org.apereo.cas.authentication.AuthenticationEventExecutionPlan;
import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer;
import org.apereo.cas.authentication.AuthenticationHandler;
import org.apereo.cas.authentication.principal.PrincipalFactory;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tr.com.example.cas.config.password.encoder.CustomPasswordEncoder;

@Configuration("CustomAuthenticationEventExecutionPlanConfiguration")
@EnableConfigurationProperties(
        {CasConfigurationProperties.class, CustomAuthenticationProperties.class})
public class CustomAuthenticationEventExecutionPlanConfiguration implements
        AuthenticationEventExecutionPlanConfigurer {

  @Autowired
  private CasConfigurationProperties casProperties;

  @Autowired
  private CustomAuthenticationProperties customAuthenticationProperties;

  @Bean
  public CustomPasswordEncoder customPasswordEncoder() {
    return new CustomPasswordEncoder();
  }

  @Autowired
  @Qualifier("jdbcPrincipalFactory")
  private PrincipalFactory principalFactory;


  @Bean
  public AuthenticationHandler myAuthHandler() {
    final CustomAuthenticationHandler handler =
            new CustomAuthenticationHandler(
                    customAuthenticationProperties
                    , null,
                    principalFactory,
                    customPasswordEncoder()
            );
    return handler;
  }

  @Override
  public void configureAuthenticationExecutionPlan(AuthenticationEventExecutionPlan plan) {
    plan.registerAuthenticationHandler(myAuthHandler());
  }

}

This configuration prepares the necessary custom handling classes and registers it to CAS. What made my head hurt was finding necessary dependant classes and initiate on the config class as spring beans(via method @Bean'ing and @Autowiring whatever it takes :) ). I looked too much at CasJdbcAuthenticationConfiguration and QueryAndEncodeDatabaseAuthenticationHandler classes to understand the innerworkings of an authentication handler. And for the last step, we need to tell spring to add our config class to register via openning the file src/main/resources/META-INF/spring.factories and adding our configuration class, we can add multiple so no worries:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.apereo.cas.config.CasOverlayOverrideConfiguration,\
tr.com.example.cas.config.auth.handler.CustomAuthenticationEventExecutionPlanConfiguration

Lastly, on our handler method(at the return line, attributes variable being used), we've added the attributes to the principal object to use, so that authentication handler does copy the db fields to authenticated user (Principal) object.

Multi Factor Authentication(MFA):

To enable mfa, first add the dependencies tobuild.gradle :

implementation "org.apereo.cas:cas-server-support-simple-mfa"

And then add these settings.

cas.authn.mfa.simple.mail.attribute-name=email
cas.authn.mfa.simple.mail.from=cas@example.com.tr
cas.authn.mfa.simple.mail.subject=cas.mfa.email.key
cas.authn.mfa.simple.mail.text=Your key is: ${token}
cas.authn.mfa.simple.sms.attribute-name=phone
cas.authn.mfa.simple.sms.from=EXAMPLE
cas.authn.mfa.simple.sms.text=Your key is: ${token}
cas.authn.mfa.simple.token.core.timeToKillInSeconds=90
cas.authn.mfa.simple.token.core.tokenLength=6
cas.authn.mfa.triggers.principal.global-principal-attribute-name-triggers=mfa
cas.authn.mfa.triggers.principal.global-principal-attribute-value-regex=mfa-simple
cas.authn.mfa.trusted.core.device-registration-enabled=false
cas.authn.mfa.simple.bypass.authentication-attribute-name=mfa-bypass
cas.authn.mfa.simple.bypass.authentication-attribute-value=.*

and of course, if your configuration is on db, add these configs as new rows on CAS_SETTINGS_TABLE.

With these settings, basic mfa setup is now complete, but we still need to do stuff for the actual mfa code sending implementation, we can configure cas email sending settings of mfa(defining smtp and stuff like written here (Read the config items in the "Email Server" tab), but for the sms side, we still need to use external libraries and configure them. For this learning project's sake, let's learn how to customize both email and sms sending operations.

To customize CAS MFA mail/sms sending capability, we need to write out own class that implements the CommunicationsManager and register as a @Bean as explained here. The explanation on CAS documentation did not say about which module this class belong to, so i have to searched it so that you don't... We need to add this as a dependency on our project. To start with MFA, First add this dependency to build.gradle:

implementation "org.apereo.cas:cas-server-core-notifications"

For simplicity's sake, i used a free and open source notification service named [ntfy.sh](https://ntfy.sh/app) to simulate both email and sms sending operations. To do this first go to https://ntfy.sh/app and click the menu item at the left named Subscribe to topic. After that a popup appears and wants to name your topic. Enter something, like:

cas-learning-mfa-demo

for your topic name name and press Subscribe button. This topic name is used in the class written next phase, you should write like this, or you can change topic name but remember update in the class below, accordingly. You can test if notifications are working by calling this command:

curl -d 'Hello, is this working?' https://ntfy.sh/cas-learning-mfa-demo

if you hear a sound and see the Hello, is this working? at ntfy.sh browser tab, you're ready! Of course this is not an actual implementation of sending emails and sms's, but Just think that these notifications are SMS and EMAILs sent to your user.

Now let's implement our CustomCustomCommunicationsManager class:

package tr.com.example.cas.config.mfa;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.apache.bval.constraints.Email;
import org.apache.commons.collections.CollectionUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.apereo.cas.authentication.principal.Principal;
import org.apereo.cas.notifications.CommunicationsManager;
import org.apereo.cas.notifications.mail.EmailCommunicationResult;
import org.apereo.cas.notifications.mail.EmailMessageRequest;
import org.apereo.cas.notifications.sms.SmsRequest;
import org.apereo.cas.util.http.HttpClient;

@Slf4j
public class CustomCustomCommunicationsManager implements CommunicationsManager {
  @Override
  public boolean isMailSenderDefined() {
    return false;
  }

  @Override
  public boolean isSmsSenderDefined() {
    return true;
  }

  @Override
  public boolean isNotificationSenderDefined() {
    return false;
  }

  @Override
  public boolean notify(Principal principal, String title, String body) {
    return false;
  }

  // Sends the email (NTFY example).
  @Override
  public EmailCommunicationResult email(EmailMessageRequest emailRequest) {

    var username = emailRequest.getPrincipal().getId();
    
    String email;
  
    // We need to understand this method is called from a password resetting operation or MFA operation.
    // When password resetting operation, this method will be invoked with emailRequest.getTo() a.k.a `to` field exists. So use it accordingly. 
    // If not, it's a MFA request and you can get the necessary email address from principal
    if (emailRequest.getTo() != null) {
      // şifre sıfırlama sırasında
      email = emailRequest.getTo().get(0);
    } else {
      // login işlemi sırasında
      email = emailRequest.getPrincipal().getAttributes().get("email").get(0).toString();
    }

    String body = emailRequest.getBody();
    if (body != null) {
      Map<String, String> result =
          sendToNtfy("EMAIL", email, body);
      boolean emailSent = "200".equals(result.get("code"));
      if (emailSent) {
        LOGGER.info("Sending mail to {} user is successful.", email);
      }
      return EmailCommunicationResult.builder().success(emailSent).build();
    }

    // i've just return here positive sent outcome. Do not forget to change and handle the negative cases! 
    return EmailCommunicationResult.builder().success(true).build();
  }

  // Send SMS(NTFY Example!)
  @Override
  public boolean sms(SmsRequest smsRequest) {
    Map result = sendToNtfy("SMS", smsRequest.getTo(), smsRequest.getText());
    System.out.println("Response Status code: " + result.get("code"));
    System.out.println("Response Body: " + result.get("body"));
    return result.get("code").equals("200");
  }

  @Override
  public boolean validate() {
    return false;
  }

  private Map<String, String> sendToNtfy(String type, String to, String body) {
    // https://www.baeldung.com/apache-httpclient-cookbook
    // https://docs.ntfy.sh/publish/
    
    HttpPost request = new HttpPost("https://ntfy.sh/cas-learning-mfa-demo");
    request.addHeader("Priority", "high");
    request.addHeader("X-Tags", "policeman");
    request.addHeader("Markdown", "yes");
    request.addHeader("Title", "CAS MFA");
    StringBuilder sb = new StringBuilder();
    sb.append("Type: ").append(type).append("\n");
    sb.append("To: ").append(to).append("\n");
    sb.append("Message Body: \n").append(body);
    try {
      request.setEntity(
          new StringEntity(sb.toString())
      );
      CloseableHttpClient httpClient = HttpClients.createDefault();
      CloseableHttpResponse response = httpClient.execute(request);
      Map<String, String> out = new HashMap<>();
      out.put("code", String.valueOf(response.getStatusLine().getStatusCode()));
      out.put("body", String.valueOf(EntityUtils.toString(response.getEntity())));
      return out;
    } catch (UnsupportedEncodingException | ClientProtocolException e) {
      throw new RuntimeException(e);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }
  
  // run this method check the ntfy sending demo yourself! :) 
  public static void main(String[] args) {
    CustomCustomCommunicationsManager cm = new CustomCustomCommunicationsManager();
    Map<String, String> result = cm.sendToNtfy("DEMO", "ME", "Hello from CAS MFA DEMO");
    System.out.println("Response Status code: " + result.get("code"));
    System.out.println("Response Body: " + result.get("body"));
  }
}

imporant note here is that isXXXXSenderDefined methods on the beginning of the class toggles the sending of these methods, and email, sms and notify methods do the actual sending operations. So i wrote the ntfy integration on a private method called sendToNtfy and use it on both email and sms.

Lastly, we need to register this class as a Spring bean, so basically add it to the configuration class(i.e: CasOverlayOverrideConfiguration in our overlay project) like this:

@RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
@Bean
public CommunicationsManager communicationsManager() {
  return new CustomCommunicationsManager();
}

To see the effect on the login, we have defined on the configuration that, any login attempt that has a mfa attribute with a value of mfa-simple will enter the mfa operation cycle. Below configurations you have entered already(look at the first mfa config list, you should see these!). These are the configurations for this functionality:

cas.authn.mfa.triggers.principal.global-principal-attribute-name-triggers=mfa
cas.authn.mfa.triggers.principal.global-principal-attribute-value-regex=mfa-simple

To test this functionality on this learning project, revisit the CustomAuthenticationHandler class. In the authenticateUsernamePasswordInternal method, before returning, add the line below to enable mfa for the users:

attributes.put("mfa",List.of("mfa-simple"));

after that, run the project and see when you enter your password, you will see that it goes to another page for mfa key and at the same time(approximately :) ) your ntfy.sh website tab will notify you that cas mfa token key. After entering the key, you will be logged in successfully.

To further your knowledge, read up on the triggering section of MFA on CAS documentation: https://apereo.github.io/cas/6.6.x/mfa/Configuring-Multifactor-Authentication-Triggers.html

Service Registry:

From start to here, all we did was logging in by using CAS Login screens, which is not all CAS can do. Other applications need security by authenticating their access points. In CAS these type of applications are called services and there's some configurations to handle these services.

Default Service Loading Mechnanism:

By default, CAS tries to open the /etc/config/services directory, and loads the service definition JSON files. These configurations looks like this: HTTPSandIMAPS-10000001.json

{
  "@class": "org.apereo.cas.services.CasRegisteredService",
  "serviceId": "^(https|imaps)://.*",
  "name": "HTTPS and IMAPS",
  "id": 10000001,
  "description": "This service definition authorizes all application urls that support HTTPS and IMAPS protocols.",
  "evaluationOrder": 10000
}

I wrote an example application(todo: add the link here) that uses CAS, and to register this as a CAS service i wrote the service registry file like this:

{
  "@class": "org.apereo.cas.services.CasRegisteredService",
  "id": 1000,
  "name": "local",
  "description": "For localhost only development services",
  "logo": "https://mythemeshop.com/wp-content/uploads/2020/07/What-Exactly-is-Localhost.jpg",
  "informationUrl": "http://localhost:8900/",
  "serviceId": "http://localhost:8900/login/cas",
  "evaluationOrder": 10,
  "multifactorPolicy": {
    "@class": "org.apereo.cas.services.DefaultRegisteredServiceMultifactorPolicy",
    "multifactorAuthenticationProviders": [
      "java.util.LinkedHashSet",
      [
        "mfa-simple"
      ]
    ],
    "bypassEnabled": true,
    "forceExecution": true
  }
}

To summarize the json file structure briefly(from what i understand):

  • @class is the type of service that CAS understands
  • serviceId is typically a regex that the service will be calling from(Be careful and always double check before setting this value)
  • name and id information should be used as both the json file name and identifying the service uniquely.
  • description is describing the service briefly.
  • evaluationOrder is for when multiple url expressions is used the same service, which will be used.

There's more properties in this JSON definition, you can enhance your knowledge about this topic here

An example defining MFA per service registry json config like this:

  
...
"multifactorPolicy" : {
"@class": "org.apereo.cas.services.DefaultRegisteredServiceMultifactorPolicy",
"multifactorAuthenticationProviders": ["java.util.LinkedHashSet", ["mfa-simple"]], // in our example we defined simple so we use its name here.
"bypassEnabled": true, // enable or disable the MFA 
"forceExecution": true // force executing MFA flow 
}
...
  

more on this:

Service Registry Configurations on Database and Refreshing:

Now, configuring these service registries in a json file is good for the start, but not very good for production use, because for every update maintainer will need to update their codebase/json folder, so, for that reason, we can move all of these configuration to database. Here are the necessary steps:

  • Add the necessary dependencies(for jpa use the jpa-service-registry):

    // JSON Service Registry: https://apereo.github.io/cas/6.6.x/services/JSON-Service-Management.html
    // implementation "org.apereo.cas:cas-server-support-json-service-registry"
    
    // JPA Service Registry: https://apereo.github.io/cas/6.6.x/services/JPA-Service-Management.html
       implementation "org.apereo.cas:cas-server-support-jpa-service-registry"
    
  • Use these settings:

    cas.service-registry.core.init-from-json=false
    cas.service-registry.json.watcher-enabled=false
    # i changed default cas behaviour to look at the `/src/main/resources/config/services` dir when learning :)
    cas.service-registry.json.location=classpath:config/services
    cas.service-registry.jpa.driver-class=org.postgresql.Driver
    cas.service-registry.jpa.dialect=org.hibernate.dialect.PostgreSQL95Dialect
    cas.service-registry.jpa.url=jdbc:postgresql://localhost:5432/postgres
    cas.service-registry.jpa.user=postgres
    cas.service-registry.jpa.password=postgres
    cas.service-registry.jpa.ddl-auto=update
    # PT15S=15 seconds is set for dev purposes!
    cas.service-registry.schedule.repeat-interval=PT15S 

    and of course for the db config people :) :

    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.core.init-from-json', 'false');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.jpa.ddl-auto', 'update');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.jpa.dialect', 'org.hibernate.dialect.PostgreSQL95Dialect');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.jpa.driver-class', 'org.postgresql.Driver');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.jpa.password', 'postgres');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.jpa.url', 'jdbc:postgresql://localhost:5432/postgres');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.jpa.user', 'postgres');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.json.location', 'classpath:config/services');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.json.watcher-enabled', 'false');
    INSERT INTO cas_settings_table (name, value) VALUES ('cas.service-registry.schedule.repeat-interval', 'PT15S');
  • After that, when you restart CAS, you'll see a brand new table, named registered_services. The json files will be converted in this table, which looks bad, but it's easy to do. I think devs did not do a full json->column conversion and you can almost copy the json text to the body column of the table, and set the other columns evaluation_order,evaluation_priority,name and service_id as the same in the json file. As conversion goes, my example client app's CAS Service Registry Row became like this:

    INSERT INTO public.registered_services (
    id,
    body, 
    evaluation_order, 
    evaluation_priority, 
    name, 
    service_id) 
    VALUES (
    1000, 
    '{
    "@class": "org.apereo.cas.services.CasRegisteredService",
    
    "description": "For localhost only development services",
    "logo": "https://mythemeshop.com/wp-content/uploads/2020/07/What-Exactly-is-Localhost.jpg",
    "informationUrl":"http://localhost:8900/",
    "serviceId": "http://localhost:8900/login/cas",
    "evaluationOrder": 10,
    
    "multifactorPolicy" : {
      "@class" : "org.apereo.cas.services.DefaultRegisteredServiceMultifactorPolicy",
      "multifactorAuthenticationProviders" : [ "java.util.LinkedHashSet", [ "mfa-simple" ] ],
      "bypassEnabled": true,
      "forceExecution": true
    }}',
    10,
    1000,
    'local',
    'http://localhost:8900/login/cas');
  • For the last step, i configured the cas.service-registry.schedule.repeat-interval property to make the CAS refresh its service registry list. For dev purposes, i did set this 15 seconds to see the effects quickly, for production this should be more reasonable value.

  • To see its effects, I changed the bypassEnabled value on the body column, and waited for CAS to reload the service registries from db. You'll see the logs saying :

    <Loaded [1] service(s) from [JpaServiceRegistry].>) 
    

    and see that MFA became toggle-able by service.

  • Important to think about these configurations is that, for our application which have a web interface and callable rest apis, we could set up entries (with proper execution order and priority) that don't require MFA's for the rest endpoints, and MFA's for the web interfaces.

Access Strategies Configurations:

this page tells important enablings of service registry configurations.

  • enabled: by adding this in your configuration, you basically disable your service to use CAS, and CAS starts to show Application Not Authorized to Use CAS error:

     "accessStrategy" : {
      "@class" : "org.apereo.cas.services.DefaultRegisteredServiceAccessStrategy",
      "enabled" : false
    }
  • ssoEnabled: by adding this in your configuration, even if you logged in to CAS from another browser tab, you still need to re-login your application through CAS when entered:

     "accessStrategy" : {
      "@class" : "org.apereo.cas.services.DefaultRegisteredServiceAccessStrategy",
      "ssoEnabled" : false
    }
  • Others: there are other configurations, which should be good to know. Check out the documentation at:

    https://apereo.github.io/cas/6.6.x/services/Configuring-Service-Access-Strategy.html

  • For advanced usage, resolving, accessing, transforming and releasing user attributes (metadata about logged in user, can be fetched anywhere) and redirecting these attribute datas to the client/service applications is another good-to-know topic.

Theme setups:

To change the default theme, use this setting below:

```
cas.theme.default-theme-name

```

To write a custom theme for CAS run this gradle command to setup the base structure for the overlay project:

```

./gradlew createTheme -Ptheme=new-theme-name

```

After that you'll see a structure like this written in the overlay project's readme file:

```
├── new-theme-name.properties
├── static
│ └── themes
│     └── new-theme-name
│         ├── css
│         │ └── cas.css
│         └── js
│             └── cas.js
└── templates
    └── new-theme-name
        └── fragments
```
  • new-theme-name.properties file contains theme configs(enable sidebar, display hero banner, web page title text configs etc).
  • static/themes/new-theme-name/ this folder contains static assets(images, fonts,css, js and other)
  • templates/new-theme-name/ this folder contains the customization of the default CAS webflow Thymeleaf html files.

You can use the cas-server-support-thymleaf dependency to see all the necessary html files to customize your furher needs. Check out the screenshots below to understand the basic 'cheat sheet' of the CAS theming folder system. What you need to do on your template is to copy the necessary files exactly the same structure on your templates/new-theme-name folder.

cas-theming-structure-1 cas-theming-structure-2 cas-theming-structure-3 cas-theming-structure-4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment