# 安全管理

# 快速启动

在使用组件的安全管理功能时,需要依赖于spring security的功能。

  1. 在项目中加入 spring security依赖
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

2 在项目中加入以下启动代码

下面的代码用户应该保证能被 @ComponentScan扫描到。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends AbstractSecurityConfig {
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		super.configure(http);
	}

}

该代码的示例代码可参见 com.yishuifengxiao.common.security.SecurityConfig (opens new window)

  1. 实现 UserDetailsService 接口,完成自己的授权逻辑,然后按照名字 userDetailsService将其注入到spring 之中

【特别注意】

  • 在用户未按照本步骤配置自己的授权逻辑时,组件会默认进行一个缺省实现。在缺省实现的情况下,用户能使用任意用户名配合密码(12345678)进行登录。
  • 加入上述配置之后,只有组件中内置的默认路径能通过授权,访问其他的url都被重定向到 /index这个地址,具体的配置及原因请参照后续说明。
  • 注入的UserDetailsService实例的名字必须为userDetailsService,否则组件会使用默认的缺省实现。

进行上述配置后,组件的安全管理功能是开启的。

在默认情况下,只要项目中加入了spring-boot-starter-security相关的依赖,假如没有加上第二部中提到的启动代码,本组件中的安全功能不会生效,但是本组件中其他相关的配置都会进行初始化并被装到到spring的上下文中。

在某些特殊情况下,如果想要彻底关闭组件中的安全管理功能,可以使用以下配置进行关闭

#是否开启安全相关的功能,默认为true
yishuifengxiao.security.enable=true



# 资源管理说明

授权资源管理是本组件中的重要部分,在本组件中所有的授权资源由以下几部分组成

  • 忽视资源
  • 匿名资源
  • 自定义资源
  • 保护资源

也就是说 授权资源 = 忽视资源+ 匿名资源 + 自定义资源 + 保护资源 = 全部资源

忽视资源

指的是不经过spring security管理,用户可以不经过授权直接无障碍访问的资源,在本组件中,默认 静态资源 和 swagger-ui请求路径为 非管理资源

匿名资源

指的是经过spring security管理,但是用户无需登录,通过匿名用户就能访问的资源。

自定义资源

指的的是需要程序根据设定自定决定是否给予访问权限的资源

保护资源

指的是需要用户登陆后才能访问的资源,该部分资源只要用户登陆成功后,无论其权限如何都能访问。

# 忽视资源

组件中内置的非管理资源的属性配置为

#是否包含actuator相关的路径,默认为true
yishuifengxiao.security.ignore.contain-actuator=true
#是否包含所有的资源,默认为true
yishuifengxiao.security.ignore.contain-all=true
#是否默认包含静态资源,默认为true
yishuifengxiao.security.ignore.contain-static-resource=true
#是否包含swagger-ui的资源 ,默认为true
yishuifengxiao.security.ignore.contain-swaager-ui-resource=true
#是否包含webJars资源,默认为true
yishuifengxiao.security.ignore.contain-webjars=true
  • actuator相关的路径为/actuator/**
  • webJars资源为 /webjars/**
  • 所有的资源表达式为 /**
  • swagger-ui的资源 为"/swagger-ui.html", "/swagger-resources/**","/v2/api-docs"
  • 系统中默认的静态资源为{ "/js/**", "/css/**", "/images/**", "/fonts/**","/**/**.png", "/**/**.jpg", "/**/**.html", "/**/**.ico", "/**/**.js", "/**/**.css", "/**/**.woff","/**/**.ttf" }

【特别注意】

请勿轻易将yishuifengxiao.security.ignore.contain-all属性配置为true,否则组件会给系统中所有的资源给予访问权限。

对于其他需要自定义为非管理资源的路径资源,用户还可以通过以下配置进行扩展

yishuifengxiao.security.ignore.urls.demo1=/aaa
yishuifengxiao.security.ignore.urls.demo2=/cc/**,/dsd/**

在上面的示例配置中demo1demo2由用户自行配置,可以配置成任意符合规范的值,这些配置属性的值即为需要配置的资源,多个授权资源间用半角逗号分开。

# 匿名资源

匿名资源就是在系统中不经过系统管理的资源。所有不经过资源授权管理的的资源路径的配置如下:

  • key: 不参与解析,可以为任意值,但必须唯一
  • value: 不希望经过授权管理的路径,采用Ant风格匹配,多个路径之间用半角逗号(,)分给开
yishuifengxiao.security.permits.demo1=url1,url2
yishuifengxiao.security.permits.demo2=url3,url4

在上面的示例配置中demo1demo2由用户自行配置,可以配置成任意符合规范的值,这些配置属性的值即为需要配置的资源,多个授权资源间用半角逗号分开。

组件默认的匿名资源为

# 权限拦截时默认的跳转地址,默认为/index
yishuifengxiao.security.core.redirect-url
# 默认的表单登陆时form表单请求的地址,默认为 /auth/form
yishuifengxiao.security.core.form-action-url
# 默认的处理登出请求的URL的路径【即请求此URL即为退出操作】,默认为 /loginOut
yishuifengxiao.security.core.login-out-url
# /oauth/token
/oauth/token
# session失效时跳转的地址,默认为 /session/invalid
yishuifengxiao.security.session.session-invalid-url

# 自定义资源

yishuifengxiao.security.customs.demo1=/aaa
yishuifengxiao.security.customs.demo2=/cc/**,/dsd/**

与非管理资源的扩展配置类似,配置中demo1demo2由用户自行配置,可以配置成任意符合规范的值,这些配置属性的值即为需要配置的资源,多个授权资源间用半角逗号分开。

【特别注意】使用此功能需要用户配置自己的自定义授权提供器,假如用户没有进行配置,组件的缺省实现会将此部分资源直接方形。

自定义授权提供器的实例代码如下:

@Component("customAuthority")
public class CustomAuthorityImpl implements CustomAuthority {
    @Override
	public boolean hasPermission(HttpServletRequest request, Authentication auth){
	    //执行自己的授权逻辑,true表示通过授权,false表示拒绝授权
	}
}

# 保护资源

在系统中所有的资源,除了忽视资源、匿名资源、自定义资源之外,其他所有的资源都需要经过登陆之后才能让访问。

# 全局配置参数

# 是否关闭cors保护,默认为false
yishuifengxiao.security.close-cors=false
# 关闭csrf功能,默认为true
yishuifengxiao.security.close-csrf=true
# 是否开启httpBasic访问,默认为true
yishuifengxiao.security.http-basic=true
# 资源名称,默认为yishuifengxiao
yishuifengxiao.security.realm-name=yishuifengxiao
# 加解密中需要使用的密钥
yishuifengxiao.security.secret-key=

在spring security的官方要求中,需要用户在spring security中注入一个 PasswordEncoder接口的实例,本组件默认采用的是 【易水工具组件】中DES加密,该加密工具是基于DES对称加密算法而来,在本组件中利用其进行加密时的密钥由yishuifengxiao.security.secret-key的值决定,用户也可以不配此值,使用系统缺省密钥值。

在使用本组件时,推荐使用组件内置的默认加密工具,在用户的系统有特殊的加密需求时,可以通过向spring 中注入一个 名为 passwordEncoderPasswordEncoder实例来实现自定义加密。

示例如下

	/**
	 * 注入自定义密码加密类
	 * 
	 * @return
	 */
	@Bean("passwordEncoder")
	public PasswordEncoder passwordEncoder(SecurityProperties securityProperties) {
		return new CustomPasswordEncoderImpl(securityProperties.getSecretKey());
	}

# 登陆表单配置

表单登陆时默认的配置参数如下:

#表单提交时默认的用户名参数,默认值为username
yishuifengxiao.security.core.username-parameter=username
#表单提交时默认的密码名参数,默认值为pwd
yishuifengxiao.security.core.password-parameter=password
#表单登陆时form表单请求的地址,默认值为 /login
yishuifengxiao.security.core.form-action-url=/login
#默认的处理登出请求的URL的路径【即请求此URL即为退出操作】,默认为/logout
yishuifengxiao.security.core.login-out-url=/logout
#系统登陆页面的地址 ,默认为/login
yishuifengxiao.security.core.login-page=/toLogin
#权限拦截时默认的跳转地址,默认为/index
yishuifengxiao.security.core.redirect-url=/index

对于登陆表单配置相关的参数,系统均有默认值,如无特殊需求,用户可以采用组件的默认值。

上面提到,在访问非授权资源时,用户请求会被自动重定向到 /index ,这是由yishuifengxiao.security.core.redirect-url这个配置决定的,当用户有特殊需求时,可以配置此属性来配置 非授权路径的重定向地址。

在表单登陆时,用户可以参考下面的表单配置

<form action="/login" method="POST">

    用户名 <input name="username" /> <br /> 
    密码 <input name="password" /> <br />
    <button>登陆</button>

</form>

登录表单中,action提交目标由yishuifengxiao.security.core.form-action-url决定,用户名和密码的请求参数分别由 yishuifengxiao.security.core.username-parameteryishuifengxiao.security.core.password-parameter决定。

在完成配置后,用户即可通过表单登陆系统了,根据用户参数的正确与否,分别有登录成功处理和登陆失败处理进行不同的逻辑处理,关于这两处理器,在后续章节会有明确介绍。

# 并发登陆管理

# 同一个用户在系统中的最大session数,默认8888 
yishuifengxiao.security.session.maximum-sessions=8888
# 达到最大session时是否阻止新的登录请求,默认为false,不阻止,新的登录会将老的登录失效掉
yishuifengxiao.security.session.max-sessions-prevents-login=false
# session失效时跳转的地址
yishuifengxiao.security.session.session-invalid-url=/session/invalid

当因多终端登录造成前置的登陆用户的登陆状态实现,可以实现 SessionInformationExpiredStrategy 接口并将其注入到spring之中,这样就可以记录由此导致的相关信息了。

# 记住我功能

# 记住我产生的token,默认为 yishuifengxiao
yishuifengxiao.security.remeber-me.key=yishuifengxiao
# 登陆时开启记住我的参数,默认为 rememberMe
yishuifengxiao.security.remeber-me.remember-me-parameter=rememberMe
#默认过期时间为60分钟
yishuifengxiao.security.remeber-me.remember-me-seconds=60
#是否使用安全cookie
yishuifengxiao.security.remeber-me.use-secure-cookie=true

# 验证码拦截

首先需要按照 【验证码使用】这一章节中发送验证码所有需要的相关的配置,然后进行如下配置

yishuifengxiao.security.code.filter.image=需要进行图形验证码验证的路径,多个路径之间用半角逗号隔开
yishuifengxiao.security.code.filter.sms=需要进行短信验证码验证的路径,多个路径之间用半角逗号隔开
yishuifengxiao.security.code.filter.email=需要进行邮件验证码验证的路径,多个路径之间用半角逗号隔开

进行上述配置之后,用户在请求相应的资源时将 【验证码使用】进行验证码验证时需要携带的请求参数放在本请求之后即可。这样就无需使用 【验证码使用】中的方法显式验证了。

此外,用户还可以用 yishuifengxiao.security.code.is-filter-get属性来修改是否过滤get方法。

#是否过滤GET方法,默认为false
yishuifengxiao.security.code.is-filter-get=false

即系统默认会对设置的GET请求进行验证码校验,如果不需要校验GET请求和将此值设置为true。

关于验证码的使用,具体方法参见 验证码使用 (opens new window)

# 短信登陆

短信登陆是与表单登陆并列的功能。在某些情况下,用户需要开发短信登陆系统功能,本组件也支持此方法。

快速开启

用户可以通过配置以下熟悉开启短信登陆功能

# 开启短信登陆需要设置
yishuifengxiao.security.code.sms-login-url=短信登陆地址
# 短信登陆参数,默认为 mobile
yishuifengxiao.security.code.sms-login-param=mobile

在短信登陆时,用户可以参考下面的表单配置

<form action="短信登陆地址" method="POST">

    手机号 <input name="mobile" /> <br /> 
    验证码 <input name="code" /> <br />
    <button>登陆</button>

</form>

其中手机号的参数由属性yishuifengxiao.security.code.smsLoginParam决定

注意事项

除此之外,还要在系统中注入一个名为smsUserDetailsServiceUserDetailsService实例,用来实现短信登陆逻辑

# 流程处理

在spring security使用过程中,总结起来主要为以下几种情况:

  • 登陆成功
  • 登陆失败
  • 退出成功
  • 用户获取授权,但是访问的资源不在授权范围内,访问被拒绝
  • 用户未获取授权,但是访问的资源不在授权范围内,访问被拒绝
  • 登陆状态已过期
  • 进行前置校验(如用户名和密码不存在或错误等情况)时发生异常

在默认情况下,系统会有自动采用默认的处理方式,即通过内置的默认处理器SimpleHandlerProcessor进行处理,该处理器继承了BaseHandlerProcessor父类,其处理接口HandlerProcessor定义如下:

public interface HandlerProcessor {

	/**
	 * 登陆成功后的处理
	 * 
	 * @param request
	 * @param response
	 * @param authentication
	 * @param  token 生成的token
	 * @throws IOException
	 */
	void login(HttpServletRequest request, HttpServletResponse response, Authentication authentication,SecurityToken token)
			throws IOException;

	/**
	 * 登陆失败后的处理
	 * 
	 * @param request
	 * @param response
	 * @param exception
	 * @throws IOException
	 */
	void failure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
			throws IOException;

	/**
	 * 退出成功后的处理
	 * 
	 * @param request
	 * @param response
	 * @param authentication
	 * @throws IOException
	 */
	void exit(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
			throws IOException;

	/**
	 * 访问资源时权限被拒绝<br/>
	 * 本身是一个合法的用户,但是对于部分资源没有访问权限
	 * 
	 * @param request
	 * @param response
	 * @param exception
	 * @throws IOException
	 */
	void deney(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception)
			throws IOException;

	/**
	 * 访问资源时因为权限等原因发生了异常后的处理<br/>
	 * 可能本身就不是一个合法的用户
	 * 
	 * @param request
	 * @param response
	 * @param exception
	 * @throws IOException
	 */
	void exception(HttpServletRequest request, HttpServletResponse response, Exception exception) throws IOException;

	/**
	 * 输出前置校验时出现的异常信息<br/>
	 * 在进行前置校验时出现了问题,一般情况下为用户名或密码错误之类的
	 * 
	 * @param request
	 * @param response
	 * @param data     响应信息
	 * @throws IOException
	 */
	void preAuth(HttpServletRequest request, HttpServletResponse response, Response<CustomException> data) throws IOException;

}

一般来说,默认的处理方式已经能满足日常需要了,但是在某些情况下,用户需要自定义处理策略,此时用户可以实现自己的HandlerProcessor的实现或者根据需要覆盖实现BaseHandlerProcessor子类,然后将其注入到spring上下文即可。

# 登陆成功

在用户登陆成功之后,系统会返回如下格式的数据

{
    "code": 200,
    "msg": "登陆成功",
    "requtest-id": "ea5f2dbc54f84d94926848b4e394d91c",
    "data": {
        "value": "176C1465A2999ED986B2E6ECCFA304E70E477EDBB3EBFCA97F15D4E74D9BE4DBE6CE28300CB101ADAC037D667C470818A747470D9DFEE4C5BEE5BCA7600A3CB428D63DA09876DB38",//访问令牌,以后每次访问都要携带
        "username": "yishuifengxiao",//登陆用户的用户名
        "sessionId": "334e5012-1663-4dad-86c0-d0fc0524fe85",//本次登陆的会话id
        "validSeconds": 43200,//访问令牌的有效时间,单位为秒
        "time": "2020-11-30 22:52:33",//访问令牌的生成时间
        "expireAt": "2020-12-01 10:52:33",//访问令牌的过期时间点
        "active": true,//该令牌是否为激活状态
        "available": true,//该令牌是否为可用状态
        "expired": false//该令牌是否已经过期
    },
    "response-time": "2020-11-30 22:52:33"
}

在上述响应数据中,value的值即为用户在以后每次请求中都需要携带上的访问令牌的值。在以后的所有的对于保护资源的请求中,用户都需要携带上该值用来标明自己的身份与权限。对于如何使用和处理令牌,在下一节中再详细介绍。

# 登陆失败

在登陆失败后组件会返回失败原因,用户可以根据此情况按需处理

一般来说,开启前置校验功能后,登陆失败这种情况通常不会存在,因为流程被前置校验接管了。

# 退出成功

在退出成功后组件会返回失败原因,用户可以根据此情况按需处理。

在退出成功后,组件会自动删除系统中的认证信息与访问令牌,用户如果再使用原来的令牌访问系统中的资源,便会被拒绝。

# 前置校验失败

用户在前置校验失败时会调用此接口,一般主要会使用到常见的场景如下:

  • 访问令牌校验失败
  • 登陆时用户名或密码失效
  • 登陆的用户账号处于异常情况
  • OAUTH2中获取令牌时账号密码异常
  • 验证码校验失败

对于有些情况下,用户可以使用以下设置关闭此功能:

# 是否关闭前置参数验证,默认为false,表示开启
yishuifengxiao.security.core.close-pre-auth=true

# 令牌校验

在用户访问系统中的保护资源,均需要携带上通过登陆接口获取到的访问令牌,只有携带上次令牌,用户才能访问系统中所有被授权的资源。在默认情况下,下面的这些资源不会经过权限校验:

  • 忽视资源
  • 匿名资源
  • 其他资源
  • 用户配置的不需要校验的资源

在默认情况下,其他资源为 表单登陆地址 、短信登陆地址。用户配置的不需要校验的资源的配置语法如下

yishuifengxiao.security.unchecks.demo1=/aaa
yishuifengxiao.security.unchecks.demo2=/cc/**,/dsd/**

在上面的示例配置中demo1demo2由用户自行配置,可以配置成任意符合规范的值,这些配置属性的值即为需要配置的资源,多个授权资源间用半角逗号分开。

如果需要关闭令牌校验功能,只需要将/**配置进去即可,例如:

yishuifengxiao.security.unchecks.demo1=/**

# 令牌生成规则

在上面的流程处理章节中提到登陆成功后会生成访问令牌,生成访问令牌的主要配置规则如下:

token的有效时间,单位为秒,默认的token有效时间,默认为24小时
yishuifengxiao.security.token.valid-seconds=86400 
#同一个账号最大的登陆数量。默认为 8888
yishuifengxiao.security.token.max-sessions=8888
#在达到同一个账号最大的登陆数量时是否阻止后面的用户登陆,默认为false
yishuifengxiao.security.token.prevents-login=false
#用户唯一标识符的标志,默认为user_unique_identitier
yishuifengxiao.security.token.user-unique-identitier=user_unique_identitier

在生成令牌时,组件会尝试获取用户的唯一标识,尝试的次序如下:

  1. 首先从请求头参数中获取
  2. 如果从请求头中没有获取到从请求参数中获取
  3. 如果还是没有获取到的话,就从session中获取获取sessionId为标识符
  4. 最后还是没有获取到值的情况下,会生成一个唯一的随机数作为用户唯一标识符的标志

【注意】:在系统的默认配置中,同一个账号最大的登陆数量的数量限制为 8888,这相当于未开启多终端登陆防护功能,如果用户想要开启账号多终端登陆防护,需要根据实际情况按需设置好用户唯一标识符的标志,以免对日常开发造成困扰。

# 令牌校验规则

在获取用户请求中访问令牌时,获取顺序如下:

  1. 首先从请求头参数中获取
  2. 如果从请求头中没有获取到从请求参数中获取
  3. 如果还是没有获取到的话,就从session中获取

在获取令牌时的配置参数如下:

#从请求头里取出认证信息时的参数名,默认为 xtoken
yishuifengxiao.security.token.header-paramter=xtoken
#从请求参数里取出认证信息时的参数名,默认为 yishui_token
yishuifengxiao.security.token.request-paramter=yishui_token

也就是说,在用户通过登陆接口获取到访问令牌后,在以后每次的请求中,都需要将该令牌放到请求头中,请求头参数的名字通过yishuifengxiao.security.token.header-paramter配置的值(例如默认的xtoken),也可以将该令牌放到请求参数,请求参数的名字由配置yishuifengxiao.security.token.request-paramter确定。

当令牌校验失败,组件会自动调用流程处理章节中提到的前置校验失败接口。在校验成功后,组件会自动刷新令牌的过期时间点,重置令牌的有效时间

在令牌校验通过后,用户可以在应用的任何地方通过以下代码获取系统中存储的令牌:


SecurityToken token = SessionStorage.get(SecurityToken.class);

# 令牌生成工具

在某些情况下,用户可能需要自定操作令牌,易水组件内置的token生成工具,其全路径如下:

com.yishuifengxiao.common.security.utils.TokenUtil

此接口的定义如下:


	/**
	 * 生成一个令牌
	 * 
	 * @param username 用户账号
	 * @param password 账号对应的密码
	 * @return 生成的令牌
	 * @throws CustomException 非法的用户信息或状态
	 */
	public static SecurityToken create(String username, String password) throws CustomException

	/**
	 * 生成一个令牌
	 * 
	 * @param username  用户账号
	 * @param password  账号对应的密码
	 * @param sessionId 会话id
	 * @return 生成的令牌
	 * @throws CustomException 非法的用户信息或状态
	 */
	public static SecurityToken create(String username, String password, String sessionId) throws CustomException

	/**
	 * 生成一个令牌
	 * 
	 * @param request  HttpServletRequest
	 * @param username 用户账号
	 * @param password 账号对应的密码
	 * @return 生成的令牌
	 * @throws CustomException 非法的用户信息或状态
	 */
	public static SecurityToken create(HttpServletRequest request, String username, String password)
			throws CustomException 

	/**
	 * 生成一个令牌
	 * 
	 * @param username 用户账号
	 * @return 生成的令牌
	 * @throws CustomException 非法的用户信息或状态
	 */
	public static SecurityToken createUnsafe(String username) throws CustomException

	/**
	 * 生成一个令牌
	 * 
	 * @param username  用户账号
	 * @param sessionId 会话id
	 * @return 生成的令牌
	 * @throws CustomException 非法的用户信息或状态
	 */
	public static SecurityToken createUnsafe(String username, String sessionId) throws CustomException

	/**
	 * 生成一个令牌
	 * 
	 * @param request  HttpServletRequest
	 * @param username 用户账号
	 * @return 生成的令牌
	 * @throws CustomException 非法的用户信息或状态
	 */
	public static SecurityToken createUnsafe(HttpServletRequest request, String username) throws CustomException

# 过滤器链表

以下是Spring Security过滤器顺序的完整列表:

# 自定义拦截器

如果要在spring security过滤器链中加入一个自定义拦截器,通过使用易水组件,只需要注入一个com.yishuifengxiao.common.security.filter.SecurityRequestFilter的子类即可。

下面是一个使用示例

package com.yishuifengxiao.common.security.filter.impl;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.BooleanUtils;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import com.yishuifengxiao.common.security.extractor.SecurityExtractor;
import com.yishuifengxiao.common.security.filter.SecurityRequestFilter;
import com.yishuifengxiao.common.security.processor.HandlerProcessor;
import com.yishuifengxiao.common.security.resource.PropertyResource;
import com.yishuifengxiao.common.security.support.SecurityHelper;
import com.yishuifengxiao.common.security.token.SecurityToken;
import com.yishuifengxiao.common.tool.exception.CustomException;

import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class UsernamePasswordAuthFilter extends SecurityRequestFilter {


	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

         //这里进行业务逻辑处理
		filterChain.doFilter(request, response);
	}

	@Override
	public void configure(HttpSecurity http) throws Exception {
        //这里著名把当前过滤器配置到spring security过滤器链的哪个位置,此配置必不可少
		http.addFilterBefore(this, UsernamePasswordAuthenticationFilter.class);

	}
}

Last Updated: 1/19/2021, 10:39:52 AM