安全

身份鉴定

用户身份鉴定的主要代码从一个对 UserFacade.loginUser() 方法的调用开始。实际身份鉴定是调用的Apache Shiro。 ExecutionContextFactoryImpl内部的使用Shiro的SecurityManager来鉴定的代码基本类似这样:

    UsernamePasswordToken token = new UsernamePasswordToken(username, password)
    Subject loginSubject = eci.getEcfi().getSecurityManager() .createSubject(new DefaultSubjectContext())
    loginSubject.login(token)

Shiro被配置为默认使用MoquiShiroRealm,所以它最后会调用 MoquiShiroRealm.getAuthenticationInfo()方法,使用 moqui.security.UserAccount 实体来鉴定,并处理诸如被禁用的账号、跟踪失败的登录尝试这类事情。 这里有 shiro.ini 文件中的相关配置:

    moquiRealm = org.moqui.impl.util.MoquiShiroRealm
    securityManager.realms = $moquiRealm

Shiro能被配置为使用其他的鉴定机制,例如Shiro自带的CasRealm、JdbcRealm或者JndiLdapRealm。你也能实现你自己的,甚至修改MoquiShiroRealm类来更好地满足你的需求。 Shiro有文档说明如何编写你自己的实现,并且提供的每个类都有配置文档,例如 [如何配置JndiLdapRealm连接LDAP服务器|http://shiro.apache.org/static/1.4.0/apidocs/org/apache/shiro/realm/ldap/JndiLdapRealm.html] 。

对于默认使用的MoquiShiroRealm,这里是它的默认配置(来自MoquiDefaultConf.xml文件,能被运行时Moqui配置XML文件覆盖):

 <user-facade>
        <password encrypt-hash-type="SHA-256" min-length="8" min-digits="1" min-others="1"  history-limit="5" change-weeks="104" email-require-change="false" email-expire-hours="48"/>
        <login-key encrypt-hash-type="SHA-256" expire-hours="144"/><!-- default expire 6 days, 144 hours -->
        <login max-failures="3" disable-minutes="5" history-store="true" history-incorrect-password="false"/>
 </user-facade>

login 元素配置在禁用账号前允许的最大失败登录次数( max-failures ),当达到最大登录次数时禁用账号多长时间( disable-minutes ),是否保存登录尝试的历史到UserLoginHistory实体(history-store)以及是否在历史中保存错误密码(history-incorrect-password)。

login-key 元素被用于配置login/api密钥。encrypt-hash-type 指定使用什么哈希算法,expire-hours指定密钥多长时间过期。

password 元素被用于配置在创建账号(org.moqui.impl.UserServices.create#UserAccount)或者更新密码(org.moqui.impl.UserServices.update#Password)时的密码约束条件。

设置包括在保存密码、和输入的密码比较前对密码的哈希算法,(encrypt-hash-type; 有MD5,SHA,SHA-256,SHA-384,SHA512),最小的密码长度(min-length),密码中数字字符最小个数(min-digits),非数字字母字符的最小个数(min-others),在密码变更时要记录多少个旧密码以避免使用曾用过的密码(history-limit),过多少周后强制修改密码(change-weeks)。

重设忘记的密码的主要方式是通过电子邮件,电子邮件中包含一个随机生成的密码。email-require-change 属性指定在首次登录时是否需要更改随邮件发送的随机密码。email-expire-hours属性指定在email中的密码多少个小时后过期。

@startuml

package org.apache.shiro {
    package authc.Shiro {
        interface AuthenticationInfo {
            PrincipalCollection getPrincipals();
            Object getCredentials();
        }
        interface AuthenticationToken {
            Object getPrincipal();
            Ojbect getCredentials();
        }
    }
    package realm {
        interface Realm {
            String getName();
            boolean supports(AuthenticationToken token);
            AuthencticationInfo getAuthentication token);
        }
    }
    package authz {
        interface Authorizer {
            isPermitted
            isPermittedAll
            checkPermission
            hasRole
            hasRoles
            hasAllRoles
            checkRole
            checkRoles
        }
    }
    package authc {
        interface AuthenticationToken {
            getPrincipal
            getCredentials
        }
        interface HostAuthenticationToken {
            getHost
        }
        interface RememberMeAuthenticationToken {
            isRememberMe
        }
        class UsernamePasswordToken {
        }
    }
}

AuthenticationInfo <.. Realm
AuthenticationToken <.. Realm

AuthenticationToken <|-- HostAuthenticationToken
AuthenticationToken <|-- RememberMeAuthenticationToken
HostAuthenticationToken <|-- UsernamePasswordToken 
RememberMeAuthenticationToken <|-- UsernamePasswordToken 

package org.moqui.impl.util {
    class MoquiShiroRealm {
    }
    class ForceLoginToken {
        saveHistory
    }
}

Realm <|--- MoquiShiroRealm
Authorizer <|--- MoquiShiroRealm

UsernamePasswordToken <|-- ForceLoginToken 

@enduml
  • getAuthenticationInfo
    1. loginPrePassword
      1. 根据用户名依次在缓存、数据库查找用户账号
      2. 根据emailAddress在数据库查找用户账号
      3. 如果没有找到,抛出 UnknownAccountException 异常
      4. 确定用户未被禁用
    2. 如果不是forceLogin
      1. 根据用户账号的密码编码类型获取 CredentialsMatcher
      2. CredentialsMatcher.doCredentialsMatch
      3. 如果未匹配,则同步调用 org.moqui.impl.UserServices.increment#UserAccountFailedLogins
    3. loginPostPassword
      1. 判断是否需要修改密码(是否有requirePasswordChange标识,passwordSetDate是否已超出时限)
      2. 确定用户可从当前IP登录(根据用户所在用户组判断)
      3. 更新用户登录信息
      4. 更新moqui.server.Visit信息
    4. 如果不是forceLogin,或者是forceLogin且saveHistory,则 loginAfterAlways
      1. 如果需要统计历史信息,则更新 moqui.security.UserLoginHistory 记录

简单权限

鉴权最基础的是用代码明确地检查权限。构件级的鉴权(下节涉及)通常更灵活,因为它配置在构件(页面、服务等)外,并且是可继承的以避免构件(特别是服务)重用时的问题。

检查权限的API是 ec.user.hasPermission(String userPermissionId) 方法。当一个用户是一个有权限(UserGroupPermission)的组(UserGroup)的成员( UserGroupMember )时,他拥有权限。userPermissionId 可指向一个 UserPermission 记录,但它的值也可以是任意的字符串,因为UserGroupPermission没有到UserPermission的外键。

构件级鉴权

Moqui中的构件级鉴权使得能够在外部配置对诸如页面、页面转换、服务以及甚至实体这样的构件的访问。通过这种方式,无需对每个构件添加代码或配置以明白当前用户是否有权限访问该构件。

构件执行栈和历史

框架的各部分都使用了 ArtifactExecutionFacade 来跟踪每个执行的构件。它有一个当前正执行构件的栈,每个构件在开始时(用一个 push() 方法)入栈,并在结束时(用一个 pop() 方法)出栈。每个构件被推入栈时,也被加到一个当前的 ExecutionContext (如单个web请求、远程服务调用等)中的所有构件的历史中。

使用 ArtifactExecutionInfo peek() 方法来获取栈顶的构件的信息,使用 Deque getStack() 方法来获取整个当前栈,使用 List getHistory() 来获取所有执行过的构件的历史。

这对于构件级的鉴权很重要,因为鉴权记录是可继承的。如果一个构件鉴权被配置为可继承,那么不仅是那个构件被鉴权,它用到的任意构件也被鉴权。

设想一个有数百个页面和转换、数千个服务、数百个实体的系统。为每个配置鉴权在初始设置和后续的维护时都将需要非常大的努力。并且也会非常容易出错,错误地允许和拒绝对构件的访问,导致敏感数据或功能的暴露,或者在用户尝试执行某些有权执行的重要操作时出运行时错误。

解决方案是可继承的鉴权。用它可以对整个应用或一个应用的部分设置权限,为一个页面配置了权限,那么所有子页面、转换、服务和实体都将继承此配置。为了限制范围,敏感的服务和实体可以设拒绝权限来覆盖该可继承的权限,表示访问那些构件需要特定的授权。 这种方法灵活、简单,能对敏感资源进行粒度控制。

这种方法也被用在跟踪每个构件的性能。细节请查看 构件执行运行时优化

public interface ArtifactExecutionFacade {
    // .... 省略 ....
    ArtifactExecutionInfo peek();
    void push(ArtifactExecutionInfo aei, boolean requiresAuthz);
    ArtifactExecutionInfo push(String name, ArtifactExecutionInfo.ArtifactType typeEnum, ArtifactExecutionInfo.AuthzAction actionEnum, boolean requiresAuthz);
    ArtifactExecutionInfo pop(ArtifactExecutionInfo aei);

    Deque<ArtifactExecutionInfo> getStack();
    List<ArtifactExecutionInfo> getHistory();

    boolean disableAuthz();
    void enableAuthz();

    boolean disableTarpit();
    void enableTarpit();

    void setAnonymousAuthorizedAll();
    void setAnonymousAuthorizedView();
    // .... 省略 ....
}
public interface ArtifactExecutionInfo {
    // .... 省略 ....
    String getName();
    ArtifactType getTypeEnum();
    String getTypeDescription();
    AuthzAction getActionEnum();
    String getActionDescription();

    String getAuthorizedUserId();
    AuthzType getAuthorizedAuthzType();
    AuthzAction getAuthorizedActionEnum();
    boolean isAuthorizationInheritable();
    boolean getAuthorizationWasRequired();
    boolean getAuthorizationWasGranted();

    long getRunningTime();
    BigDecimal getRunningTimeMillis();
    long getThisRunningTime();
    BigDecimal getThisRunningTimeMillis();
    long getChildrenRunningTime();
    BigDecimal getChildrenRunningTimeMillis();
    List<ArtifactExecutionInfo> getChildList();
    ArtifactExecutionInfo getParent();
    BigDecimal getPercentOfParentTime();
    // .... 省略 ....
}

构件鉴权(Authz)

配置构件鉴权的第1步是创建一个构件组。这包含一条 ArtifactGroup 记录和每个构件一条 ArtifactGroupMember 记录或者在组中配置一个构件名称的匹配模式。

例如示例应用的构件组例子,根界面(ExampleApp.xml)作为组的一个成员。

    <xml>
    <moqui.security.ArtifactGroup artifactGroupId="EXAMPLE_APP" description="Example App (via root screen)"/>
    <moqui.security.ArtifactGroupMember artifactGroupId="EXAMPLE_APP" artifactTypeEnumId="AT_XML_SCREEN" inheritAuthz="Y" artifactName="component://example/screen/ExampleApp.xml"/>
    </xml>

在此例中 artifactName 属性的值为页面的位置。它也可以是一个构件名称的匹配模式(用nameIsPattern="Y"),这对于为一个包中的所有服务或实体设置鉴权特别有用。下面的例子把 org.moqui.example包中的全名匹配正则表达式“org.moqui.example.*”的所有服务加入到组中。

<moqui.security.ArtifactGroupMember artifactGroupId="EXAMPLE_APP" artifactName="org.moqui.example..*" nameIsPattern="Y" artifactTypeEnumId="AT_SERVICE" inheritAuthz="Y"/>

下一步是用一条ArtifactAuthz记录为构件组配置授权。下面是一个给 ADMIN 组设置总有权限(AUTHZT_ALWAYS)对前面设置的EXAMPLE_APP构件组中的构件执行所有动作(AUTHZA_ALL)。

<moqui.security.ArtifactAuthz artifactAuthzId="EXAMPLE_AUTHZ_ALL" userGroupId="ADMIN" artifactGroupId="EXAMPLE_APP" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/>

“总是”(AUTHZT_ALWAYS)类型(authzTypeEnumId)授权覆盖了“拒绝”(AUTHZT_DENY)授权,不像“允许”(AUTHZT_ALLOW)授权会被“拒绝”(AUTHZT_DENY)覆盖。authz动作的其他选项除了“所有”(AUTHZA_ALL)外还包括“查看”(AUTHZA_VIEW)、“创建”(AUTHZA_CREATE)、“更新”(AUTHZA_UPDATE)和“删除”(AUTHZA_DELETE)。

下面是一条用“允许”(这样可以被“拒绝”)类型授予只读授权给EXAMPLE_VIEWER组的记录:

<moqui.security.ArtifactAuthz artifactAuthzId="EXAMPLE_AUTHZ_VW" userGroupId="EXAMPLE_VIEWER" artifactGroupId="EXAMPLE_APP" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_VIEW"/>

实体构件鉴权也可用特定的 ArtifactAuthzRecord 实体记录来限制。用一个视图实体(viewEntityName),其目标记录和当前登录用户的userId联结。如果userId字段名不是userId,那可以用 userIdField 字段来指定字段名。 记录级别的鉴权,用当前的userId和操作对应实体的主键字段在视图实体上执行一个查询来检查。要给这个查询加约束,你可以用filterByDate属性把约束加到视图实体定义,或用ArtifactAuthzRecordCond记录来指定条件。

如果当一个构件被使用时鉴权失败,框架将创建一条带有相关细节的ArtifactAuthzFailure记录。

实体过滤集与授权

@startuml
object ArtifactAuthzFilter {
    artifactAuthzId : pk
    entityFilterSetId : pk
}
object EntityFilterSet {
    entityFilterSetId : pk
    applyCond
}
object EntityFilter {
    entityFilterId : pk
    entityName : 要过滤的实体名
    filterMap : groovy表达式
    comparisonEnumId
    joinOr
}
object ArtifactAuthz {
    artifactAuthzId : pk
    userGroupId
    artifactGroupId
    authzTypeEnumId
    authzActionEnumId
    authzServiceName
}

ArtifactAuthzFilter *-- "1" EntityFilterSet
ArtifactAuthzFilter *-- "1" ArtifactAuthz
EntityFilterSet o-- "N" EntityFilter

@enduml

通过使用ArtifactAuthzFilter实体来配置,自动查询增补(augmentation,添加条件到find/select查询)可被用来过滤记录。这尝试把记录级别的授权加到应用(页面等)的鉴权。和一个ArtifactAuthz关联的每个过滤集有多个条件表达式保存在EntityFilterSet实体中。 每条(EntityFilter)记录有一个实体名(entityName)表示当(直接或通过一个视图实体)查询时要被过滤的实体。也有一个FilterMap,它是一个Groovy表达式,其结果应当是一个Map。虽然在视图实体上可以执行过滤,但并不是一个好做法,因为直接查询实体或其他的视图实体容易有数据泄露,所以过滤通常只在纯实体上定义,而不是视图实体。

张振宇: 对于多数据表关联,过滤是定义在视图上还是成员表上,老戴建议定义在表上。逻辑上解释其实就是源头数据做好行权限,不要在业务聚合的时候再去做行权限。

对于包含了基于动态视图实体的DataDocument的视图实体和动态视图实体,为了让过滤器应用到查询,过滤器用到的每个字段都必须包含在定义中。这意味着带过滤器的实体也必须被包括在视图中。例如为了按活跃或按用户组织过滤,OrderItem上的视图实体或报表也应包含OrderParty实体上的customerPartyId和vendorPartyId字段。

Groovy表达式有点复杂难以理解,下面是一个有过滤器以及包含的字段、逻辑的例子。Moqui自带的例子在Mantle USL组件中,基于组织来过滤记录。该表达式使用了两个“总是”可用的变量(需要支持组织过滤的应用都需要在根页面的always-action中赋值):表示用户所选的组织的ID的‘activeOrgId’和应当用于过滤结果的‘filterOrgIds’,如果没有选择组织,那么activeOrgId为当前用户所在的所有组织的partyId。

<moqui.security.EntityFilterSet entityFilterSetId="MANTLE_USER_ORG" description="User Organization Filters">
<!-- ... -->
</moqui.security.EntityFilterSet>
<moqui.security.EntityFilterSet entityFilterSetId="MANTLE_ACTIVE_ORG" description="Active Organization Filters">
<!-- ... -->
</moqui.security.EntityFilterSet>

构件 Tarpit

A tarpit(wikipedia) automatically trap visitors or bots that reach a particular path on your site.

构件Tarpit限制对一个组中的构件的访问流量。下面例子中有一个包含所有页面的构件组,一个对于每个页面如果在60秒(maxHitsDuration)内超过120个点击(maxHitsCount)时限制所有用户60秒(tarpitDuration)中不能再访问此页面的ArtifactTarpit。

<xml>
<!-- 省略 -->
<moqui.security.ArtifactGroup artifactGroupId="ALL_SCREENS" description="All Screens"/>
<moqui.security.ArtifactGroupMember artifactGroupId="ALL_SCREENS" artifactName=".*" nameIsPattern="Y" artifactTypeEnumId="AT_XML_SCREEN"/>
<moqui.security.ArtifactTarpit userGroupId="ALL_USERS" artifactGroupId="ALL_SCREENS" maxHitsCount="120" maxHitsDuration="60" tarpitDuration="60"/>
<!-- 省略 -->
</xml>

当一个特定用户(userId)超出了为一个特定构件(artifactName)或一个特定类型(artifactTypeEnumId)的构件配置的流量限制,框架会创建一个ArtifactTarpitLock记录来限制用户访问该构件直到某个时间(releaseDateTime)。

results matching ""

    No results matching ""