Apereo CAS 使用MongoDB作为Service注册的存储

可以以不同的方式保存 Servcie 的注册信息,参考这里获得完整列表:https://apereo.github.io/cas/6.5.x/services/Service-Management.html#storage。
这里只记录下如何设置 MongoDB 作为存储端,官方文档:https://apereo.github.io/cas/6.5.x/services/MongoDb-Service-Management.html。

1. 添加依赖

implementation "org.apereo.cas:cas-server-support-mongo-service-registry"

2. 设置

cas.service-registry.mongo.client-uri=mongodb://casdb_user:password@localhost:27017/cas_db
cas.service-registry.mongo.collection=cas_serviceregistry

3. 服务注册初始化

参数 cas.service-registry.core.init-from-json 用于控制在 CAS 系统启动时对发现的 json 格式的服务注册信息是否导入到对应的后端存储。默认为 true,这样启动时会把用 json 文件定义的服务注册信息写入到后端存储,这里是 cas_db 的 cas_serviceregistry collection。
开发初期可以使用 json 的方式调试,然后通过 init-from-json=true 把数据导入 DB 之后,再设置 init-from-json 为 false。

4. 设置 CAS-Management 使用 MongoDB 作为服务注册信息源。

CAS Management 应用之前通过下面的信息找到注册的服务数据,使用 MongoDDB 作为存储后,需要在 cas-management 的 build.gradle 中添加依赖:

implementation "org.apereo.cas:cas-server-support-mongo-service-registry"

同时 删除掉 cas.service-registry.json.location 的设置,并把把 cas-server 中关于 cas.service-registry.mongo 的配置 copy 过来。
cas.service-registry.mongo.client-uri=mongodb://casdb_user:password@localhost:27017/cas_db
cas.service-registry.mongo.collection=cas_serviceregistry

5. 通过 WebUI 注册一个新的 CAS Service。

通过 Web 界面添加一个新的 CAS Client 之后,查看 MongoDB 的 cas_serviceregistry collection,可以看到一个新的 document 数据被成功创建出来。



Reference:
[1]: https://apereo.github.io/cas/6.5.x/services/AutoInitialization-Service-Management.html

Apereo CAS 之 用户认证

上篇使用默认的用户名密码登录 casuser/Mellon 登录 cas。我们可以通过 etc/cas/config/cas.properties 配置不同的后端存储用来进行用户信息的 authentication 的校验。

这里使用 MongoDB 作为用户信息认证的后端存储,参考这里官方文档:https://apereo.github.io/cas/6.5.x/authentication/MongoDb-Authentication.html。
主要是三个步骤,但需要先把 cas-server-support-mongo 加到 build.gradle 文件。

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

1)在 MongoDb 中保存用户信息

获得密码’md5password’的 MD5 值,并保存到 collection。
$ md5 -s ‘md5password’
MD5 (“md5password”) = ec85070aa70e598eda72cbe82d99fabc

db.cas_user.insert({
    "username": "casuser",
    "password": "ec85070aa70e598eda72cbe82d99fabc",
    "first_name": "john",
    "last_name": "smith"
})

2) 配置 cas 从 MongoDB 获取用户信息

通过直接设置 client-uri 表明连接到 mongoDB 的哪个库做认证,就不用再分别设置注入 host、database 这样的参数了。
cas-user是保存用户数据的 collection。
如果因为我们使用 MD5 作为密码摘要来验证,所以这里 password-encoder.type 设置为 DEFAULT,encoding-algorithm 设置为 MD5。可以查看源码 DefaultPasswordEncoder 理解这个设置,诸如 BCrypt 是不需要 encoding-algorithm 的。
cas 也支持 BCRYPT、PBKDF2 这样的密码编码。也可以把 type 设置为一个自己实现的 PasswordEncoder。
如果密码是明文保存的,则可把 password-encoder.type 设为 NONE。

# -- Use MongoDB as the authentication data source.
cas.authn.mongo.client-uri=mongodb://admin:password@localhost:27017/center0
cas.authn.mongo.collection=cas_user
# cas.authn.mongo.database-name=
# cas.authn.mongo.host=localhost=
# cas.authn.mongo.password=
# cas.authn.mongo.port=27017
# cas.authn.mongo.principal-transformation.groovy.location=
# cas.authn.mongo.user-id=
cas.authn.mongo.password-encoder.type=DEFAULT
cas.authn.mongo.password-encoder.encoding-algorithm=MD5

3)登录验证

运行./gradlew clean copyCasConfiguration build run,在浏览器输入 casuser / md5password 进行登录。

4)使用 BCrypt

BCrypt 是当前最通用的 password encoding 方式了。BCrypt 会自己内部产生一个随机 salt 并和 hash 的结果保存在一起作为 encode 的结果。这样每次做 BCrypt 的结果都不同并且再校验时也无需提供 salt。可参考这篇文章spring-security-registration-password-encoding-bcrypt

生成一个密码 bcpassword 的 BCrypt 值:

$ brew tap spring-io/tap
$ brew install spring-boot
$ spring encodepassword bcpassword
{bcrypt}$2a$10$fJc2wH.Oc1SES8Ju/fCoFOjqs6CRnIgPAbUXqRJQ.DGnBVTGG.bLy

更新数据库里的 password 为$2a$10$fJc2wH.Oc1SES8Ju/fCoFOjqs6CRnIgPAbUXqRJQ.DGnBVTGG.bLy

设置 cas.authn.mongo.password-encoder.type=BCRYPT
注释掉 cas.authn.mongo.password-encoder.encoding-algorithm。

重新使用新配置启动 cas server,用密码 bcpassword 登录。这个时候 cas 后台就已经使用 BCrypt 来验证密码了。
./gradlew clean copyCasConfiguration build run

其实 Type=BCRYPT 就对应到了 class org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder。

5)使用多个不同的 DataSource 用于认证

可同时使用多个不同的 DataSource 用于认证,但是相同类型的数据源只能有一个。
对于同时使用多个认证源的情况,关键是要设置好 authentication policy。请参考:Authentication ManagerAuthentication Policy

同时用 json file, texfile,MongoDB 作为认证源的一个例子

如果需要同时设置两个 MongoDB 作为认证源,就需要自己通过继承 AbstractUsernamePasswordAuthenticationHandler 来实现了。



_References_:
[1]: https://apereo.github.io/cas/6.5.x/authentication/Configuring-Authentication-Components.html
[2]: https://apereo.github.io/cas/6.5.x/authentication/Configuring-Authentication-Policy.html
[2]: https://fawnoos.com/2018/06/12/cas53-authn-handlers/

MongoDB脚本:集合中字段数据大小的分位数统计

查询某个 collection 的文档大小分布

日常开发中,有时需要了解数据分布的一些特点,比如这个 colllection 里 documents 的平均大小、全部大小等,来调整程序的设计。
对于系统中已经存在大量数据的情况,这种提前分析数据分布模式的工作套路(最佳实践)可以帮助我们有的放矢的进行设计,避免不必要的过度设计或者进行更细致的设计。

如果想获得某个 collection 相关的各种存储统计信息,可以使用 **statscollStats**。

如果想获取总计、平均等简单的统计信息,可以参考这里:https://www.mongodb.com/docs/manual/core/aggregation-pipeline/#std-label-aggregation-pipeline。

下面的命令可以显示 COLLECTION 中满足条件 status=’active’,字段 FIELD_A, FIELD_B 的数据大小的 Quantile analysis。
如果去掉第三行,就是对 COLLECTION 中所有文档数据的大小分布的统计。

//最大的Top10和百分比分布。
db.COLLECTION.aggregate([
  { $match: { "status": 'active' } },
  { $project: { _id: 1, FIELD_A: 1, FIELD_B: 1} },
  { $project: { documentSize: { $sum: { $bsonSize: "$$ROOT" } } } },
  { $sort: { documentSize: 1 } },
  { $group: { '_id': null, 'value': { '$push': '$documentSize' } } },
  { $project: { _id: 0,
      "Top1": { $arrayElemAt: ["$value", { $floor: { $add: [-1, { $size: "$value" }] } } ] },
      "Top2": { $arrayElemAt: ["$value", { $floor: { $add: [-2, { $size: "$value" }] } } ] },
      "Top3": { $arrayElemAt: ["$value", { $floor: { $add: [-4, { $size: "$value" }] } } ] },
      "Top4": { $arrayElemAt: ["$value", { $floor: { $add: [-5, { $size: "$value" }] } } ] },
      "Top5": { $arrayElemAt: ["$value", { $floor: { $add: [-6, { $size: "$value" }] } } ] },
      "Top6": { $arrayElemAt: ["$value", { $floor: { $add: [-7, { $size: "$value" }] } } ] },
      "Top7": { $arrayElemAt: ["$value", { $floor: { $add: [-7, { $size: "$value" }] } } ] },
      "Top8": { $arrayElemAt: ["$value", { $floor: { $add: [-8, { $size: "$value" }] } } ] },
      "Top9": { $arrayElemAt: ["$value", { $floor: { $add: [-9, { $size: "$value" }] } } ] },
      "Top10": { $arrayElemAt: ["$value", { $floor: { $add: [-10, { $size: "$value" }] } } ] },
      "99-9%": { $arrayElemAt: ["$value", { $floor: { $multiply: [0.999, { $size: "$value" }] } } ] },
      "99%": { $arrayElemAt: ["$value", { $floor: { $multiply: [0.99, { $size: "$value" }] } } ] },
      "95%": { $arrayElemAt: ["$value", { $floor: { $multiply: [0.95, { $size: "$value" }] } } ] },
      "90%": { $arrayElemAt: ["$value", { $floor: { $multiply: [0.90, { $size: "$value" }] } } ] },
      "50%": { $arrayElemAt: ["$value", { $floor: { $multiply: [0.50, { $size: "$value" }] } } ] },
      "25%": { $arrayElemAt: ["$value", { $floor: { $multiply: [0.25, { $size: "$value" }] } } ] },
    } },
  ]);

对 MongoDB 4.2 以上版本,可以使用上述 aggrigation 语法进行统计。对 4.2 及其以下版本,需要使用 forEach 语句迭代进行计算。
对于不支持 Object.bsonsize 的 Mongo shell 可以使用 BSON.calculateObjectSize 进行替代。

// const BSON = require("bson");
var fieldsSize = []
var totalSize = 0
db.COLLECTION.find({ "status": 'A' }, { FIELD_A: 1, FIELD_B: 1}).forEach(function(doc)
{
  var size = Object.bsonsize(doc)
//   var size = BSON.calculateObjectSize(doc)
  totalSize += size
  fieldsSize.push(size)
});
var sortedData = fieldsSize.sort(function(a, b){return a-b});
var statistic = {
    "Note": "The Collection Fields(FIELD_A, FIELD_B) Size Statistic Reuslt",
    "numbOfDocs": sortedData.length,
    "totalSize": (totalSize / 1024) + "KB    " +  (totalSize / (1024 * 1024)) + " MB",
    "AVG:": Math.round(totalSize / sortedData.length),
    "MIN": sortedData[0],
    "TOP1": sortedData[sortedData.length - 1],
    "TOP2": sortedData[sortedData.length - 2],
    "TOP3": sortedData[sortedData.length - 3],
    "TOP4": sortedData[sortedData.length - 4],
    "TOP5": sortedData[sortedData.length - 5],
    "TOP6": sortedData[sortedData.length - 6],
    "TOP7": sortedData[sortedData.length - 7],
    "TOP8": sortedData[sortedData.length - 8],
    "TOP9": sortedData[sortedData.length - 9],
    "TOP10": sortedData[sortedData.length - 10],
    "99.9%": sortedData[Math.floor(sortedData.length * 0.999)],
    "99%": sortedData[Math.floor(sortedData.length * 0.99)],
    "95%": sortedData[Math.floor(sortedData.length * 0.95)],
    "90%": sortedData[Math.floor(sortedData.length * 0.90)],
    "75%": sortedData[Math.floor(sortedData.length * 0.75)],
    "50%": sortedData[Math.floor(sortedData.length * 0.5)],
    "25%": sortedData[Math.floor(sortedData.length * 0.25)],
    "10%": sortedData[Math.floor(sortedData.length * 0.10)]
};

print(statistic)


_References:
[1]: https://database.guide/2-ways-to-get-a-documents-size-in-mongodb/
[2]: https://stackoverflow.com/questions/22008822/how-to-get-the-size-of-single-document-in-mongodb
[3]: https://www.mongodb.com/docs/v4.2/reference/command/collStats/
[4]: https://database.guide/mongodb-binarysize/
[5]: https://database.guide/mongodb-object-bsonsize/
[6]: https://flowygo.com/en/blog/mongodb-compass-extract-statistics-using-aggregation-pipeline/
[7]: https://www.mongodb.com/docs/manual/aggregation/

MongoDB的用户管理

如果在配置文件中 eanble 了 authorization,那么用这样的配置启动 MongoDB 后。如果不是 root 用户去创建用户会遇到一些权限问题。

security:
  authorization: enabled

/usr/local/opt/mongodb-community/bin/mongod –config /usr/local/etc/mongod.conf

如果你忘记的 root 用户或者其它管理员用户的密码,那么可以先以无权限控制的方式启动 MongoDB:

mongod --port 27017 --dbpath /usr/local/var/mongodb

再连接到 MongoDB:

mongo --port 27017

根据需要可以更改用户密码或者创建新用户:

db.changeUserPassword("root", passwordPrompt())
db.changeUserPassword("root", "password")

db.createUser({
    "user": "dahui",
    "pwd": "password",
    "roles":
    [
        {
            "role": "userAdminAnyDatabase",
            "db": "admin"
        },
        "readWriteAnyDatabase"
    ]
});

添加完这种带有 userAdminAnyDatabase 角色的用户后,关闭 MongoDB server:

db.adminCommand( { shutdown: 1 } )

再以有权限控制的方式重新启动 MongoDB Server。(/usr/local/etc/mongod.conf 中设置了 authorization: enabled)

mongod --config /usr/local/etc/mongod.conf
或
mongod --auth --port 27017 --dbpath /var/lib/mongodb

这样使用拥有 userAdminAnyDatabase 角色的用户登录后,就可以通过使用 use DB_NAME 的方式给不同的 DB 添加用户了。

mongosh --port 27017 -u admin -p password --authenticationDatabase admin

mongosh "mongodb://admin:password@127.0.0.1:27017/center0?authSource=admin"


Reference:
[1]: https://www.mongodb.com/docs/v5.0/tutorial/enable-authentication/
[2]: https://www.mongodb.com/docs/manual/reference/method/js-user-management/

Authentication in Spring Security

关于 Spring Security 里的 Authentication,官方文档总结的不错。理解这些 classes 的作用与关系是正确使用 Spring Security Authentication 的前提。

认证的方式不同,认证逻辑就不同,这样每个认证方式都会有对应的 fitler 实现。执行认证的大致流程以 AbstractAuthenticationProcessingFilter 为例描述一下。不同类别的 Authentication Filter 的处理略有差异,但大体逻辑差不多:

  1. Authentication Filter 接收请求 http request。
  2. 从 request 中获取凭证(credential)等数据,封装在Authentication对象中,比如:OAuth2LoginAuthenticationToken, UsernamePasswordAuthenticationToken 等。
  3. AuthenticationManager向对其传入的 Authentication 进行实际的认证工作。
  4. 认证成功的处理,比如保存设置了授权信息的 Authentication 到SecurityContext中。
  5. 失败进行处理。

1) Authentication Filter 接收请求

当用户发送了 http request 进行认证,将被负责 authentication 的 filter 处理。这些 filters 实际的认证工作大多数(不是全部)都是由 AuthenticationManager 完成的。比如,像 AbstractPreAuthenticatedProcessingFilter 这些类本身就是接收的是第三方已经认证的请求,所以无需 AuthenticationManager。 另外像 AnonymousAuthenticationFilter 也无需 AuthenticationManager 的参与。
所以虽然都是认证,但是因为不同场景处理的逻辑不同,所以与 AuthenticationFilter 相关类的父类并不相同。大致可以分成以下三类。

1)继承自 AbstractAuthenticationProcessingFilter 的 authentication fitler class 有 3 个。

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter
public class Saml2WebSsoAuthenticationFilter extends AbstractAuthenticationProcessingFilter
AbstractAuthenticationProcessingFilter

2)继承自 AbstractPreAuthenticatedProcessingFilter 的类有 5 个。

public class RequestHeaderAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter
public class RequestAttributeAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter
AbstractPreAuthenticatedProcessingFilter

3)其它分别直接继承 OncePerRequestFilter 和 GenericFilterBean,比如:

OncePerRequestFilter 较 GenericFilterBean 可以保证只被 filters 处理一次。

public class BearerTokenAuthenticationFilter extends OncePerRequestFilter
public class BasicAuthenticationFilter extends OncePerRequestFilter

public class AnonymousAuthenticationFilter extends GenericFilterBean
public class RememberMeAuthenticationFilter extends GenericFilterBean

2)Request to Authentication

Authentication 这个类在认证前主要用于承载认证需要的凭证信息,比如用户名密码。authentication 对象也就等同于一个 authentication request 的 event,并包含请求者进行认证所必须的信息。

3)AuthenticationManager

authentication 对象会传递给AuthenticationManager 的方法 authenticate()做认证。
AuthenticationManager 是个 interface,它的实现类如下图片所示。

在认证后,principal 的授权信息会被写在 authentication 对象的 authorities 字段。下图摘自《Spring Security in Action》,使用 username password 做认证。

如上图所示的,具体的认证工作是委托给AuthenticationProvider完成的。在 Spring Security 的代码实现中,也并不是由 AuthenticationManager 直接包含一组 AuthenticationProvider 的方式完成,中间还有一个叫做ProviderManager的类,下面列出它的两个关键字段体会下。

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

    private List<AuthenticationProvider> providers = Collections.emptyList();

    private AuthenticationManager parent;
    ... ...
}

可以看到 ProviderManager 包含的不是一个 provider 而是 a list of providers。通过提供一组 providers 就向用户提供了更多灵活控制的可能性。当然随之而来的就是这里就需要明确定义 providers 的认证结果以谁为准的规则。源码 authenticate()的 doc 说得很明白:

Attempts to authenticate the passed Authentication object.
The list of AuthenticationProviders will be successively tried until an AuthenticationProvider indicates it is capable of authenticating the type of Authentication object passed. Authentication will then be attempted with that AuthenticationProvider.
If more than one AuthenticationProvider supports the passed Authentication object, the first one able to successfully authenticate the Authentication object determines the result, overriding any possible AuthenticationException thrown by earlier supporting AuthenticationProviders. On successful authentication, no subsequent AuthenticationProviders will be tried. If authentication was not successful by any supporting AuthenticationProvider the last thrown AuthenticationException will be rethrown.

让我们对比一下 AuthenticationManger 和 AuthenticationProvider 这两个 interface 的定义。看了下面的定义,你会不会问一个问题:既然两个接口有一个一摸一样的方法 authentication(),为什么不让 AuthenticationProvider 继承 AuthenticationManager? 我想或许是为了明确两个类的职责吧。

以图形的方式看看它们的关系:

如果我们要实现某个特殊的在 Spring 里没有的认证方式,我们就需要实现自定的 AuthenticationProvider 并通过覆盖 WebSecurityConfigurerAdapter 里的 configure()方法实现。

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {

@Autowired
private AuthenticationProvider authenticationProvider;

@Override
protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(authenticationProvider);
}

不过这个类已经被官方 API 文档标为 Deprecated,并推荐使用 HttpSecurity 定义 SecurityFilterChain 的方式或者通过 WebSecurityCustomizer 来配置 WebSecurity。参考源码:

到这里我们应该已经知道具体的认证逻辑都在 AuthenticationProvider 里。想知道 Spring Security 提供了哪些开箱即用的 provider 吗?见下图,一共 17 个。

再捋一下与认证相关的类,就结束这篇吧。虽然没有涉及过多细节,相信理解了这些脉略应该也能在 copy-past 代码的时候点点头了。。。
SecurityContextHolder:保存 SecurityContext 的地方。
SecurityContextHolderStrategy:定义 SecurityContext 在线程中共享的策略模式。如果要跨线越 Spring 管理的线程,请参考 。。。。
SecurityContext - 认证成功后 Authentication 对象就放在这里。
Authentication - 存放要认证的信息以及被 AuthenticationManager 认证后的结果,认证成功后被放入 SecurityContext。
GrantedAuthority - 请求认证的 principal 认证成功后被赋予的权限(i.e. roles, scopes, etc.)
AuthenticationManager - authentication 相关的 filter 调用这个对象做认证。
AbstractAuthenticationProcessingFilter:各个认证相关 filter 的父类。
ProviderManager - AuthenticationManager 的一个实现.
AuthenticationProvider - 由 ProviderManager 用来做具体的认证。
AuthenticationEntryPoint: 用于询问并接收用户的 credentials,可以是重定向到一个网页或者发送 http WWW-Authenticate response。

我想现在我们看到下面这些类时,就应该能够大致知道/理解他们在 Spring Security Authentication 类图里的位置了吧?
UserDetails, User
UserDetailsService, UserDetailsManager, JdbcUserDetailsManager
PasswordEncoder
如果没有,一定是我还没描述清楚。



References:
[1]: https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
[2]: 《Spring Security in Action》

Apereo CAS 之 管理界面

类似 cas-server,先下载 cas-management-overlay 代码,这里使用 6.5 分支。

$ git clone https://github.com/apereo/cas-management-overlay cas-management
$ cd cas-management
$ git checkout -b 6.5 origin/6.5

cas-management 应用本身也需要用户认证之后才能使用。这里使用它自己要管理的 cas-server 作为认证源。这时,cas-manager 本身就是 cas-server 的一个 client 或者说 service 了。
因此需要首先 把 management 配置为 cas-server 的一个 client/service

Apereo 支持不同的存储 service 注册的方式:json 文件、JPA、Redis …,这里使用 json 文件的方式把 cas-management 注册为 cas-server 的 service,即让 cas-manager 使用 cas-server 来认证用户。

1)在 cas-server 的 build.gradle 中添加依赖。

可参考官方文档:https://apereo.github.io/cas/6.5.x/services/Service-Management.html#storage

implementation "org.apereo.cas:cas-server-support-json-service-registry:${project.'cas.version'}"

2)告诉 cas-server 在哪里可以找到定义 client/service 的 json 文件

在文件 etc/cas/config/cas.properties 中加入如下配置:
cas.service-registry.json.location=classpath:/services

在 cas-server 项目的 src/resources 目录下创建一个名为 casManagement-2000.json 的文体。文件名的格式是 clientName + clientId.json,具体请阅读官方文档。

{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps)://.*",
  "name" : "casManagement",
  "id" : 2000,
  "logoutType" : "BACK_CHANNEL",
  "logoutUrl" : "https://localhost:8444/cas-management/logout"
}

3)配置 cas-management

上面两步是配置 cas-server 以把 cas-manager 作为它的 client。现在,配置 cas-management。
编辑 cas-management 项目里的 etc/case/config/management.properties 文件,内容如下。

cas.server.name=https://localhost:8443
cas.server.prefix=${cas.server.name}/cas

server.port=8444
mgmt.server-name=https://localhost:8444
mgmt.admin-roles[0]=ROLE_ADMIN
mgmt.user-properties-file=file:/etc/cas/config/users.json

logging.config=file:/etc/cas/config/log4j2-management.xml

server.ssl.key-store=file:/etc/cas/thekeystore
server.ssl.key-store-password=changeit

# Let the cas-management to know where to find/save the registed services
cas.service-registry.json.location=file:YOUR_PATH_TO_DIR_CONTAINS_JSONFILES/services

需要注意的是要保证 cas.server 和 mgmt server 的端口不要冲突,一个是 8443,一个是 8444。
这里方便起见,cas-management 和 cas-server 共享了同一个 keystore。

配置项cas.service-registry.json.location告诉 cas-management 到哪里去读写(管理)定义 client/service 的 json 文件。这个目录
是和 cas-server 的 cas.service-registry.json.location=classpath:/services 是一致的。

4) 同时运行 cas-server,cas-management 后,访问 https://localhost:8444/cas-management/

CAS-Management-UI

Apereo CAS 之 在本地运行

Apereo CAS,是 CAS 协议official reference implementation,也差不多是当前开源的 SSO 解决方案最好、最成熟的一个了。
当前版本是 6.5,https://github.com/apereo/cas-overlay-template/tree/6.5。

本以为按照Apereo CAS 的官方安装指南能够很容易把 cas server 在本地跑起来,但最后发现这个文档实操性略差。这里略过基础概念,直接记录一下本地运行的步骤。

没必要下载源码修改代码、配置然后 build 出自己的安装包,按照官方文档推荐直接使用“WAR Overlay Installation”的方式安装。

1)下载 overlay 框架代码,使用 6.5 分支代码。

git clone https://github.com/apereo/cas-overlay-template.git cas-server
git checkout -b 6.5 origin/6.5

Overlays 这个方式是通过 maven-war-plugin 实现的: https://maven.apache.org/plugins/maven-war-plugin/overlays.html,
gradle 的实现:https://docs.freefair.io/gradle-plugins/current/reference/#_io_freefair_war_overlay
在这个 cas 项目里可参看 ./gradle/springboot.gradle 里 bootWar 部分。

2)gradle.perperties

这利用这个 overlays 项目运行 CAS 之前,可浏览一下 gradle.properties 文件定义的各个属性。其中,
appServer 用于定义 Apereo CAS server 使用哪个内置 server(Tomcat、Jetty…),如果只生成 war 部署到外部已存在的 servlet contaier 则无需定义此项。
certDir、serverKeyStore、exportedServerCert、storeType 这些选项用于定义 https 所用的证书。

3)gradle/tasks.gradle

./gradlew tasks 运行会显示可执行的 tasks,其中一部分 task 被定义于 gradle/tasks.gradle 文件中。

CAS tasks
---------
casVersion - Display the current CAS version
copyCasConfiguration - Copy the CAS configuration from this project to /etc/cas/config
createKeystore - Create CAS keystore
createTheme - Create theme directory structure in the overlay
... ...

其中 createKeystore 用于生成 https 所用的证书。
sudo ./gradlew createKeystore

如果需要修改 keystore 的密码,可以执行:
keytool -storepasswd -keystore /etc/cas/thekeystore

生成 https 需要的 PK 和证书后,需要把 CA 导入到 JDK 的的 ca 根证书库中。

cd /etc/cas
sudo keytool -import -alias cas_cert -storepass changeit -file cas.crt -keystore  /Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home/lib/security/cacerts

查看 ca keystore
sudo keytools -list -storepass changeit -keystore /Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home/lib/security/cacerts

从 ca keystore 中删除 cascert
sudo keytool -delete -alias cas_cert -keystore /Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home/lib/security/cacerts -storepass changeit

4)cas 的配置文件

把项目中的 etc/cas/config 目录下的默认配置内容 copy 到 /etc/cas/config 中。可以手工 copy 文件,也可以通过 gradle task, ./gradlew copyCasConfiguration

默认的内置的用户名密码是 casuser/Mellon,可以通过修改/etc/cas/config/cas.properties cas.authn.accept.users=casuser::password 进行定义。

配置登录用户在 Apereo 里属于CAS Authentication 的范畴

5)Build and Run

./gradlew clean build
java -jar ./build/libs/cas.war

在浏览器中访问 https://localhost:8443/cas 进入 CAS 系统。

6)

如果在日志里发现提示The generated key MUST be added to CAS settings, 则可按照提示把 cas.tgc.crypto.encryption.key,cas.tgc.crypto.signing.key 加入到 etc/cas/config/cas.properties 文件。

    2022-08-29 23:29:34,160 WARN [org.apereo.cas.util.cipher.BaseStringCipherExecutor] - <Secret key for encryption is not defined for [Ticket-granting Cookie]; CAS will attempt to auto-generate the encryption key>
    2022-08-29 23:29:34,168 WARN [org.apereo.cas.util.cipher.BaseStringCipherExecutor] - <Generated encryption key [rAHG_XeYnE-DbLtE77fngWAMtbB5lpIXYKbI_nSiD8I] of size [256] for [Ticket-granting Cookie]. The generated key MUST be added to CAS settings:

            cas.tgc.crypto.encryption.key=rAHG_XeYnE-DbLtE77fngWAMtbB5lpIXYKbI_nSiD8I

    >
    2022-08-29 23:29:34,170 WARN [org.apereo.cas.util.cipher.BaseStringCipherExecutor] - <Secret key for signing is not defined for [Ticket-granting Cookie]. CAS will attempt to auto-generate the signing key>
    2022-08-29 23:29:34,170 WARN [org.apereo.cas.util.cipher.BaseStringCipherExecutor] - <Generated signing key [cOlc7xQB5UJTwYrVmA30aQehEkxbSkcyHmE8vRkPHboRZkTn2rBJ8pUUPZfTJt7H8e3ecpitvuH2prrLxfIVxg] of size [512] for [Ticket-granting Cookie]. The generated key MUST be added to CAS settings:

            cas.tgc.crypto.signing.key=cOlc7xQB5UJTwYrVmA30aQehEkxbSkcyHmE8vRkPHboRZkTn2rBJ8pUUPZfTJt7H8e3ecpitvuH2prrLxfIVxg


Reference:
[1]: https://apereo.github.io/cas/6.5.x/index.html
[2]: https://github.com/apereo/cas-overlay-template/tree/6.5
[3]: https://medium.com/swlh/install-cas-server-with-db-authentication-8ff52234f52

Kubernetes Ingresses (1)

在连接上一个 K8S cluster 后执行下面的命令可以看到系统中的 ingressclasses。这篇文字用来帮助自己理解下面几行简单的输出。

╰─$ kubectl get ingressclass
NAME       CONTROLLER                     PARAMETERS                             AGE
awslb      ingress.k8s.aws/alb            IngressClassParams.elbv2.k8s.aws/alb   20d
nginx      nginx.org/ingress-controller   <none>                                 30d
os-nginx   k8s.io/ingress-nginx           <none>                                 30d

Mental Model

在 Kubernets 里经常会提到 Pod,Service,Ingress,Ingress Controller, Ingress Class,那他们之间有什么逻辑关系呢?

Pod

Pod 用于把几个相关的 containers 封装在一起对外提供业务服务,containers 之间可以直接通过 localhost 通讯。而如果想访问 POD 服务只能凭借 POD 的 IP,这个 IP 也是 K8S 集群内部可见,而 POD 的 IP 在每次重建后都会变化,这显然是不可接受的。

Service

Service 就是为了解决这个问题而生,通过 service.yaml 可以定义 1)这个 service 的 name/namespace;2)由 selector 定义这个 service 对应的 PODs;3)再通过定义 service port 和 pod port 的映射关系,就可以通过 Service 的名称访问 PODs 提供的服务了。Service 借助自己对 Pod 自动发现的能力、服务名到 POD IP 的解析能力、简单的负载均衡能力,成为在 Kubernets 集群内部暴露 Pod 的不二之选。

Ingress / Ingress Controller / Ingerss Class

Service 解决了我们在 k8s 集群内部访问‘服务’的问题。如果想从集群外部访问‘服务’呢?这正是“Ingress 机制”七层路由存在的意义。这里的 Ingress 机制由 Ingress Controller、Ingress 这两个概念组成。
作为码农,接触较多的一般是 Ingress。这是因为 Ingress Controller 一旦部署到 Kubernetes Cluster 就很少会再去改动,而需要经常改动的应用路由规则都是在 Ingress 这个 Kubernets API 对象(或者说是在 ingress.yaml 文件)完成的。实际上,Ingress Controller 实例才是真正执行将用户请求路由到 Service 进而到 Pod的部件。Ingress 只是我们定义请求到 Service 的路由规则的对象。

既然“ingress“的核心功能就是 7 层路由/反向代理,那么借助早已存在的 Nginx、HAProxy 等产品实现 IngressController 就是很自然的想法了。另一个 ingress controller 的实现类别可以划分到 service mesh 阵营,比如 Istio Ingerss、Gloo 等。
k8s 官网列出的一些 Ingerss Controller 实现
这篇文章详细讲解了各种 Ingress Controller 的特性以方便我们根据自己项目的需求做出选择。直接贴上文章的干货图片:
kubernetes-ingress-comparison

在一个 Kubernets 集群里可以定义多个不同 Ingress Controller 实现/类型,那么 Ingress 对象如何知道自己的数据是提供给哪个 Ingress Controller 的呢?

在 Kubernetes 1.18 之前,是通过在 Ingress 的一个annotation kubernets.io/ingress.class 来声明。
在 Kubernetes 1.18 正式引入了一个新的k8s 资源对象 IngressClass帮助 Ingress 定义它绑定到哪个 IngressController
下面是一个官网的 IngressClass 对象定义示例,spec.controll定义了 IngressController 的实现, spec.parameters 相当于定义了你可以在 Ingerss 对象里可以向这个 IngressController 对象能够传递的参数,当然这些参数也是这种 IngressControll 必须支持的。不同的 Ingress Controller 实现其需要的 parameter 肯定是不同的,而 k8s 1.18 之前通过 annoation 给 IngerssController 传递参数的方式就显得比较随意无章可循了,这应该也是 IngressClass 出现的一个原因。

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: external-lb
spec:
  controller: example.com/ingress-controller
  parameters:
    apiGroup: k8s.example.com
    kind: IngressParameters
    name: external-lb

有了 IngressClass,那么在 Ingress 中只要设置 spec.ingressClassName 为某个 IngerssClass 的名字,那么就意味着这个 Ingress 的配置就会被这个 IngerssClass 所对应的 IngressController 所获取并被这个 IngressControll 生成为对应的路由 rules,从而完成把一个集群外请求路由到 Service 的功能。

以上就是关于 Kubernetes 里 Ingerss 的几个基本概念。

有关 Nginx 的 IngressController

基于 Nginx 实现的 IngressController 分为Kubernets社区版Nginx版

Kubernets 社区版由 Kubernetes 社区和 F5 Nginx 工程师基于开源的 Nginx 实现,其官网 code doc

Nginx 版自己又分为免费的基于开源 Nginx 的 IngressController 实现和商业版。Nginx 开源版code doc

所以就开源的版本来说,一个是 Kubernets 社区版,一个是 Nginx 开源版,两个都是基于开源的 Nginx 实现的,只是 owner 不通。表现在 IngressClass 的定义中,就是字段 spec.controller 的值一个是 Kubernets 社区版的 k8s.io/ingress-nginx, 一个是 nginx 开源版的 nginx.org/ingress-controller

下面这个表格列出了 Nginx Ingress Controller 的 Kubernets 社区版和 Nginx 开源版的区别。可以看到,两者差别不大,k8s 社区版功能略好于 Nginx 开源版。而 Nginx 开源版因为没有使用 Lua 性能又好于 k8s 社区版。

Aspect or Feature kubernetes/ingress-nginx nginxinc/kubernetes-ingress with NGINX nginxinc/kubernetes-ingress with NGINX Plus
Fundamental
Authors Kubernetes community NGINX Inc and community NGINX Inc and community
NGINX version Custom NGINX build that includes several third-party modules NGINX official mainline build NGINX Plus
Commercial support N/A N/A Included
Implemented in Go/Lua (while Nginx is written in C) Go/Python Go/Python
Load balancing configuration via the Ingress resource
Merging Ingress rules with the same host Supported Supported via Mergeable Ingresses Supported via Mergeable Ingresses
HTTP load balancing extensions - Annotations See the supported annotations See the supported annotations See the supported annotations
HTTP load balancing extensions – ConfigMap See the supported ConfigMap keys See the supported ConfigMap keys See the supported ConfigMap keys
TCP/UDP Supported via a ConfigMap Supported via custom resources Supported via custom resources
Websocket Supported Supported via an annotation Supported via an annotation
TCP SSL Passthrough Supported via a ConfigMap Supported via custom resources Supported via custom resources
JWT validation Not supported Not supported Supported
Session persistence Supported via a third-party module Not supported Supported
Canary testing (by header, cookie, weight) Supported via annotations Supported via custom resources Supported via custom resources
Configuration templates See the template See the templates See the templates
Load balancing configuration via Custom Resources
HTTP load balancing Not supported See VirtualServer and VirtualServerRoute resources See VirtualServer and VirtualServerRoute resources
TCP/UDP load balancing Not supported See TransportServer resource See TransportServer resource
TCP SSL Passthrough load balancing Not supported See TransportServer resource See TransportServer resource
Deployment
Command-line arguments See the arguments See the arguments See the arguments
TLS certificate and key for the default server Required as a command-line argument/ auto-generated Required as a command-line argument Required as a command-line argument
Helm chart Supported Supported Supported
Operator Not supported Supported Supported
Operational
Reporting the IP address(es) of the Ingress controller into Ingress resources Supported Supported Supported
Extended Status Supported via a third-party module Not supported Supported
Prometheus Integration Supported Supported Supported
Dynamic reconfiguration of endpoints (no configuration reloading) Supported with a third-party Lua module Not supported Supported

再回到文章开头的命令输出,是不是看到的更多了些?

╰─$ kubectl get ingressclass
NAME       CONTROLLER                     PARAMETERS                             AGE
awslb      ingress.k8s.aws/alb            IngressClassParams.elbv2.k8s.aws/alb   20d
nginx      nginx.org/ingress-controller   <none>                                 30d
os-nginx   k8s.io/ingress-nginx           <none>                                 30d

References:
[1]: Ingress
[2]: IngressController
[3]: IngressClass
[4]: Comparing Ingress Controllers for Kubernetes
[5]: 基于 Nginx 的 Ingress Controller 在社区和商业版之间的比较
[6]: Kubernetes 社区版
[7]: Nginx 开源版
[8]: Nginx Ingress Controll 社区版和 Nginx 开源版的比较

如何解决 No converter found capable of converting from type org.bson.BsonUndefined 问题

因为 MongoDB 数据中有的字段值为’undefined’,程序程序访问到这个数据时抛出如下 exception

org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type org.bson.BsonUndefined to type XXXXX.

我们可以通过一下几步解决这个问题:

1)我们首先需要分析是什么情况导致数据中存在 undefined 值。

从 BSON 的规范 https://bsonspec.org/spec.html 看,undefined 已经是 depricated。数据库中出现 undefined 的多半说明程序有问题,所以需要找出是在哪里、什么情况下向 MongoDB 写入了 undefined value 并进行修改。

2)如果这个问题仅存在于开发、测试环境,而不是遗留数据导致。

那么修正代码出错的地方并清理数据库中的脏数据就可以了。

3)如果这种脏数据是遗留数据并且在生产环境也是存在的。

那么我们可以通过添加一个 converter 类

BsonUndefinedToNullObjectConverterFactory implements ConverterFactory<BsonUndefined, Object>

把 undefined 转为任意类型对象的 null,就可以避免如上的 Exception。代码如 stackoverflow 上的这篇帖子。把 Converter 传给 mongoTemplate,我们就需要定义一个 MongoCustomConversions Bean。为什么需要这样的 Bean,可以参考 AbstractMongoClientConfiguration 的源代码。因为 AbstractMongoClientConfiguration 中已经定义了一个 MongoCustomConversions bean,我们就需要给自己的 Bean 加上@Primary,以便让 spring-data-mongo 优先使用我们设置了自定义 converter 的 MongoCustomConversions bean。

如果你的 spring application 还是通过 XML 方式进行 beans 定义与组装的,那么你就不能通过定义一个 ConverterFactory 来方便地把 undefined 转为任意类型对象的 null 了。这是因为 XML 不支持类型化参数。这时,只能把 converter 一个个地定义出来。XML 的组装大致如下:

<mongo:mapping-converter id="mappingConverter" >
    <mongo:custom-converters>
        <mongo:converter>
            <bean class="your.package.UndefinedToLongNullReadConverter"/>
        </mongo:converter>
        <mongo:converter>
            <bean class="your.package.UndefinedToStringNullReadConverter"/>
        </mongo:converter>
    </mongo:custom-converters>
</mongo:mapping-converter>

4)到这里即使数据库里有脏数据,程序也能‘愉快’地运行了。问题似乎已经被彻底解决了,其实没有。

因为生产环境的脏数据还没有被清理,我们现在只是容忍了脏数据的存在。在当前微服务架构下,这样的数据可能会被多个不同的微服务访问到,这就意味着这些微服务都要使用如上所述的一个 converter 才能避免 exception。我们有必要发现这些脏数据存在的位置,并进行清理。
通过在 converter 返回 o -> null 之前,执行下面的代码就可以通过 log 看到是哪个 DAO 触发了这个转换,进而可以分析出哪个 collection 存在脏数据。如果我们清理了这个 collection 的所有脏数据之后这种 undefined 脏数据还是会产生出来,那么我们就应该好好 review 一下之前的代码是哪里有问题并进行修改了。

StackTraceElement[] causes = Thread.currentThread().getStackTrace();
for(StackTraceElement st : causes){
    if (st.toString().indexOf("YOUR_DAO_PACKAGE") >= 0) {
        log.warn(st.toString());
    } else {
        log.info(st.toString());
    }
}

如果是使用 JDK9 及以上,那么可以使用 StackWalker 避免 getStackTrace()的性能损耗。可以参考 https://stackoverflow.com/questions/2347828/how-expensive-is-thread-getstacktrace。

写了个 Demo 来复现并解决这个问题,代码可参考这里

Reference:

https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#mapping-chapter

Spring提供的对数据(库)访问的几个套路

不使用 Spring 框架的情况下,Java 访问 RDBMS 会通过原始的 JDBC 或者借助 MyBatis、Hibernate、Jooq 这些能够进行对象封装的库。
MyBatis 在国内挺流行的,在欧美背景的企业里基本没有使用。

在 Spring 的世界里进行 SQL 数据库访问,基本可分为三种方式:1)Spring JDBC;2)Spring Data JDBC;3)Spring Data JPA。

1)Spring JDBC;

使用 JdbcTemplate 进行各种数据库操作。对于实体类不用加注任何 annotation。在@Repository 类中通过 jdbcTemplate 操作数据库数据。

2)Spring Data JDBC;

这个就有些领域对象的味道。每个实体类需要定义@Id (org.springframework.data.annotation.Id)字段。
定义接口@Repository  继承 CrudRepository<T, R>,在方法上标注@Query 定义查询语句,而无需实现。有 Spring 自动生成实现类,底层就是借助 JdbcTemplate、NamedParameterJdbcTemplate。
在 pom 中需导入 spring-data-jdbc,并使用@EnableJdbcRepositories 进行配置。。

3)Spring Data JPA。

Spring Data JPA 内部基于 Hibernate 这样的 ORM 实现,可以看作是 spring 对 JPA 的封装(解决方案)。
实体类需要标注@Entity、@Table 定义对应的表,还需要有个@Id(javax.persistence.Id)字段。
定义的@Repository 接口中可以通过@Query 标注查询语句之外还可以通过约定的 metho name 自动生成查询。

对于 NoSQL 数据库,Spring 对不同的数据库提供对应的模块进行支持。对于 MongoDB 来说就是 spring-boot-starter-data-mongodb。

4)MongoRepository

实体类上使用标注@Document,属性字段上可使用@Id(org.bson.types.ObjectId),@Indexed,@Field 等。
定义的@Repository 接口继承 MongoRepository<T, R>,同 Spring Data JPA 一样:方法名上加@Query 定义查询、也可通过约定的 metho name 自动生成查询。spring 自动生成实现类。

5)ReactiveMongoRepository

MongoDB 驱动是支持 reactive 的。Spring 通过 spring-boot-starter-data-mongo-reactive 进行支持。
实体类如使用 MongoRepository 一样,通过@Document、@org.bson.types.ObjectId 标识自己。@Repository 接口继承 ReactiveMongoRepository<T, R>即可。这样就可以愉快地使用 Flux、Mono 了。

上面简单的罗列了一下是 Spring 对数据访问支持的几个方案。没有特殊要求,作为业务开发使用相对高层的 JPA 应该是不错的选择。如果进行响应式编程,根据后台数据服务的不同选用 ReactiveMongoRepository、ReactiveCRUDRepository。

6)测试 ReactiveMongoRepository

如果是使用 Junit5,通过 org.junit.jupiter.api.Test 测试,那么只要在测试类上标注@DataMongoTest 即可。
如果是基于 Junit4,则需要在测试类上除了@DataMongoTest 还需再标注@RunWith(SpringRunner.class)。
@DataMongoTest 的作用是“disable full auto-configuration and instead apply only configuration relevant to MongoDB tests.” 因为对 DAO 这个层面进行测试完全没必要引入类似 WebContext 这样耗能的上下文环境,如下所示即可。

@DataMongoTest
public class UserDaoTest {

    @Autowired
    private ReactiveMongoUserRepository userRepository;

    @Test
    public void testCreateUser() {
      ... ...