高强同学的安全域实现分析文章,文章比较长,都是认真分析总结的干货,读完本文能让你清楚了解在 Tomcat 中是如何实现安全域并进行认证授权的 。以下为正文。




一、简介


为了实现 Servlet 规范中规定的对于特定资源的保护,Tomcat 提供了安全域的功能实现。如果应用使用了安全域保护系统资源,安全域就需要对每一次的访问负责,结合 Tomcat的访问流程,可以想到安全域的认证器是作为一个阀门(Valve)来实现的。

Tomcat实现了多种多样的安全域满足不同的用户需求:


  • 配置快捷、利用数据源进行认证的DataSourceRealm,

  • 更为简易的JDBCRealm,

  • 通过第三方Ldap服务器认证的JNDIRealm,

  • 限制失败次数防止暴力破解的LockOutRealm,

  • 通过文本文件配置用户信息、一般用于开发、测试的MemoryRealm,


Tomcat安全域的默认实现UserDatabaseRealm、灵活的用户自定义的JaasRealm,他们都实现了Realm接口,并拥有共同的父类RealmBase



二、Realm接口


Realm接口是安全域模块的核心接口,其提供了几个重要的方法:authenticate()方法以及多个重载用于提供用户名、密码等方式的认证功能;hasResourcePermission()方法用于认证器(Authenticator)判断当前角色是否有权限访问资源,该方法通过调用hasRole()等方法进行判断;而hasUserDataPermission()方法则对数据传输层的传输要求进行判断。




通过上述几个方法,就能够大致勾勒出一次通过安全域的请求访问的流程:用户请求资源,经过各层阀门后走到安全域(认证器),安全域首先要判断对目标资源的请求是否符合数据传输层的要求;然后通过authenticate()方法判断当前用户是否经过认证,如果没有认证则向用户请求认证信息;通过认证后,则进行角色和权限的判断;最后,根据认证结果继续请求流程或者直接返回请求拒绝信息。


Realm接口使用了Principal、SecurityConstraint、X509Certificate等接口或类,Principal是jdk api定义的表示主体的抽象概念,X509Certificate是jdk api定义的X.509 证书的抽象类,该类提供了一种访问 X.509 证书所有属性的标准方式,SecurityConstraint则是tomcat定义的对web.xml中相应

<security-constraint>元素的抽象实现。


三、RealmBase抽象类


RealmBase类对Realm接口中的大部分方法进行了实现,前面说到安全域的authenticate()认证方法提供了多种重载,其目的就是为了适用各种环境下的认证方式,毕竟并不是所有的认证信息都可以用用户名和密码的方式传递的。


例如,冗长的认证方法authenticate(String username, String clientDigest,String nonce, String nc, String cnonce,String qop, String realm,String md5a2)是为了Digest认证而设计的,而简约的authenticate(X509Certificate certs[])方法则对应https协议下的证书认证。由于特殊的需求,RealmBase的部分子类仍会重写authenticate()方法。相关的认证方法及实现,在后续介绍HTTP认证方式时再详细介绍。

 

在进行真正的认证工作前,有一步非常重要的校验工作,即当前请求是否满足定义的支持的连接类型,该处的逻辑处理由hasUserDataPermission()方法完成。


如果在web.xml的user-data-constraint节点定义了连接类型,而且连接类型不为NONE的话,Tomcat则会认为该请求需要建立在安全的连接之上,按照servlet规范定义,通过查找当前请求连接器的HTTPS重定向端口,该请求通过response.sendRedirect()的方式跳转到https请求。如果当前的请求连接器并没有配置有效的HTTPS重定向端口,则返回403 (SC_FORBIDDEN)状态码。


如果通过了前面提到的安全域认证,这说明了用户提供的用户名、密码等凭证是有效的,但这还不能够说明当前用户对目标资源具有访问的权限,所以要经过hasResourcePermission()方法,进行用户“授权”的工作。前面提到,类SecurityConstraint是对web.xml中相应


<web-resource-name>Protected Area


为了进行最后的授权工作,需要将用户的当前角色与web.xml定义的角色进行比对。不要小看短短的几行配置文件,Servlet规范进行了详尽的描述,Tomcat也遵循规范进行了细致的实现。例如:role-name的配置,通常来说,会按照业务需求将其配置为具有相应权限的用户名称,但对于通配符“*”赋予了特殊的含义。

“*”表示web.xml中定义的所有用户,“**”则表示所有通过了认证的用户。同时对于role-name为空的情况,则任何用户都不能访问相应的资源。



在认证阶段,如果用户没有通过认证,或者是第一次访问,则会拒绝该请求并返回401(SC_UNAUTHORIZED)状态码(FORM类型的认证除外,因为要跳转至登录页面);如果用户角色没有满足预先定义的权限,则会拒绝该请求并返回403 (SC_FORBIDDEN)状态码。



四、HTTP认证方法与实现


下面简单介绍JavaEE平台支持的四种认证机制:

  • Basic authentication

  • Form-based authentication

  • Digest authentication

  • Client authentication


Tomcat为实现上述认证机制,提供了多种认证器,如下图所示。认证器位处于安全域前端,对于不同类型的HTTP认证方式,先由各认证器根据相应的规范对客户端发送至服务端的信息进行解析,然后再交由安全域进行处理,例如前面提到的对当前请求是否满足定义的支持连接类型的判断就是由认证器发起的。各认证器都继承了AuthenticatorBase抽象类,其中重要的authenticate()方法由各实现类进行具体的实现。


BASIC认证


BASIC基本认证是HTTP1.0标准提出的认证方式,规范中即提出BASIC认证是不安全的用户认证方案,并支持在目前日益严重的网络安全问题面前采用更加复杂的其他认证方式及加密机制。因此,对于非SSL层请求的认证,不建议使用BASIC认证;但如果请求是在安全的传输层上,传输层提供了安全保障,即使是简单加密的BASIC认证也可以认为是安全的。BASIC认证的规则如下:


1.客户端访问受保护的资源。


2.服务器返回401 Unauthorized状态,响应头信息如下图所示,其中WWW-Authenticate:Basic realm="MyRealm"表示该资源的受保护信息。


3.浏览器根据响应弹出窗口,提示用户输入用户名和密码。


4.浏览器将客户端将输入的用户名、密码用Base64算法进行加密后发送给服务器。例如,使用用户名、密码都是“java”进行登录,浏览器则发送的请求头中包含“Authorization: Basic amF2YTpqYXZh”,其中“amF2YTpqYXZh”是用户名、密码组成的字符串“java:java”进行Base64加密得到的结果。


5.如果认证成功,则返回相应的受保护资源。如果认证失败,则仍返回401 Unauthorized状态,要求重新进行认证。



可以简单了解一下Tomcat的BASIC认证器类BasicAuthenticator的关键代码:


public boolean authenticate(Request request, HttpServletResponse response)
throws IOException {

if (checkForCachedAuthentication(request, response, true)) {
return true;
   }

// Validate any credentials already included with this request
MessageBytes authorization =
       request.getCoyoteRequest().getMimeHeaders()
       .getValue("authorization");//获取authorization请求头

if (authorization != null) {
       authorization.toBytes();
       ByteChunk authorizationBC = authorization.getByteChunk();
       BasicCredentials credentials = null;
try {
           credentials = new BasicCredentials(authorizationBC);//
Base64解密用户名密码
           String username = credentials.getUsername();
           String password = credentials.getPassword();

           Principal principal = context.getRealm().authenticate(username, password);//安全域认证
if (principal != null) {//认证成功
               register(request, response, principal,
                   HttpServletRequest.BASIC_AUTH, username, password);
return (true);
           }
       }
catch (IllegalArgumentException iae) {
if (log.isDebugEnabled()) {
log.debug("Invalid Authorization" + iae.getMessage());
           }
       }
   }

// the request could not be authenticated, so reissue the challenge
StringBuilder value = new StringBuilder(16);//认证失败返回重新认证
   value.append("Basic realm=\"");
   value.append(getRealmName(context));
   value.append('\"');
   response.setHeader(AUTH_HEADER_NAME, value.toString());
   response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return (false);

}


该认证方法的基本逻辑还是比较清晰的:


1.首先判断是否已经进行了认证,如果已经认证则没有必要重复认证,返回即可。


2.尝试取出“authorization”请求头,如果没有该请求头,则返回401Unauthorized状态码以及受保护资源的安全域信息。


3.对“authorization”请求头进行BASIC64解密,然后使用“:”切分为用户名和密码。


4.使用安全域对用户名、密码进行真正的认证工作,如果认证成功,将当前用户信息进行缓存。


Form认证


Basic认证和后面介绍的Digest认证都是rfc2616中明确定义的认证方式,抛开安全性,两者在实际使用中均有一个严重的缺点,即用户UI几乎无设计的问题。在用户体验至高无上的互联网时代,UI界面占据着很大的比重,而Basic和Digest认证由于其自身的设计,各浏览器的实现都是弹出一个无所谓美观的对话框,对用户体验有很大的影响。Form认证中定义了采集用户信息的登录页面、登录失败页面,通过用户自定义实现这两个页面,能够完成美观的登录操作。在web.xml中配置Form认证方式及登录页面示例如下:


    FORM    file            /login.xhtml        /error.xhtml


在Servlet规范中规定,使用Form认证时,表单提交的action必须为j_security_check,而获取登录信息的字段必须为j_username和j_password,这样的约定省去了相关字段的配置工作。From认证的逻辑也很清晰,下面抽取tomcat的关键代码进行解释:


1.查看是否已经对当前用户进行了认证,避免重复认证造成资源浪费:checkForCachedAuthentication(request, response, true)。


2.如果没有认证,则需要保存当前用户需要保存的页面:saveRequest(request, session),然后跳转到登录页面:forwardToLoginPage(request, response, config)。


3.用户提交了用户名和密码,进行认证工作:principal = realm.authenticate(username, password);如果认证失败,则跳转至失败页面:forwardToErrorPage(request, response, config);如果认证成功,则跳转至第二步保存的页面:response.sendRedirect(response.encodeRedirectURL(uri))。


4.浏览器接收到302重定向状态码后,将页面跳转至最初访问的页面。


5.再次走进Form认证器的认证流程,通过判断条件matchRequest(request)将认证主体(Principal)保存在Request和Session中,判断条件为:已经通过了认证;存在一个已保存的页面且与当前请求页面路径相同。然后将本次请求的所有信息都重置为最初的请求信息:restoreRequest(request, session)。此后的访问在第一步即直接返回了。

有兴趣的读者可以深入的了解一下上述的关键代码的实现。


Digest认证


Digest摘要认证是在HTTP1.1中提出的替代Basic认证的方法。由于Basic认证使用的的Base64加密几乎等于明文传输,安全性低,Digest认证提供了一种不使用明文发送用户名密码的方式。当然,HTTP1.1标准也提出,摘要访问认证语法("Digest Access Authentication scheme")并非要提供一个网络安全的完美解决方案,其目的仅仅是为了避免深受诟病的Basic认证的诸多缺点。因此,不管怎样,相比较Basic认证,Digest认证的安全性还是有所提高的。Digest认证的规则如下:


1.客户端访问受保护的资源。


2.服务器返回401 Unauthorized状态,响应头信息如下图所示,其中WWW-Authenticate:Digest realm="MyRealm", qop="auth", nonce="1454307975468:a0aefce3e84d69723e6f04fda5674ad0", opaque="23BB4CB60BFE2CD08B490A16B86C9661"表示相关的安全域信息、随机数信息(nonce)等。


3.浏览器根据响应弹出窗口,提示用户输入用户名和密码。


4.浏览器将客户端将输入的用户名以明文的方式、密码等其他信息以摘要的方式返回给服务端。


5.服务端将用户名、正确的密码等信息按规则进行摘要加密,与客户端提供的信息进行比对。如果认证成功,则返回相应的受保护资源。如果认证失败,则仍返回401 Unauthorized状态,要求重新进行认证。




其中的随机数nonce的值应当是永不重复的数值,下面看一下tomcat是怎样简单的实现并保证唯一性的:


protected String generateNonce(Request request) {

long currentTime = System.currentTimeMillis();

synchronized (lastTimestampLock) {//加锁,并发下也不会取到相同的时间
if (currentTime > lastTimestamp) {
lastTimestamp = currentTime;
       } else {
           currentTime = ++lastTimestamp;
       }
   }

   String ipTimeKey =
       request.getRemoteAddr() + ":" + currentTime + ":" + getKey();

byte[] buffer = ConcurrentMessageDigest.digestMD5(
           ipTimeKey.getBytes(StandardCharsets.ISO_8859_1));
   String nonce = currentTime + ":" + MD5Encoder.encode(buffer);

   NonceInfo info = new NonceInfo(currentTime, getNonceCountWindowSize());
synchronized (nonces) {
nonces.put(nonce, info);
   }

return nonce;
}


Tomcat使用了客户端IP地址、当前时间和Digest认证器的一个固定的key进行拼接然后进行MD5加密等最终生成nonce的。其中的固定值key是tomcat在初始化该Digest认证器时,使用的是与session id相同的方法生成,其中具体使用了JDK提供的java.security.SecureRandom随机数等,与UUID的生成方式相似,感兴趣的读者可以分析一下JDK中UUID生成唯一值的算法。可以看到,tomcat在生成nonce随机数时考虑了三方面的可能性,以保证随机数nonce的唯一性:


1.生产环境中IP地址的唯一性;


2.进行http访问时当前时间可能存在由于并发导致的不唯一性,此时会在同步块中进行对比以确保唯一。


3.同一IP地址下不同的tomcat或者不同应用的电子标签key的唯一性。

上面三个唯一性结合进行MD5加密,保证了任何可能环境下的唯一性。

用户提交用户名、密码信息后,Digest认证器将获取的相关Authorization请求头信息,正如authenticate(String username, String clientDigest,String nonce, String nc, String cnonce,String qop, String realm,String md5a2)方法中的各项参数,交由相应的安全域进行处理。其主要思想就是将用户提供的根据规则进行摘要加密生成的字符串,与服务端使用正确的密码、相同规则生成的字符串进行比对。如果用户名、密码正确,客户端提供的字符串自然与服务端生成的字符串相同,则认证通过。在此不在进行进一步阐述。


Client认证


前面提到不管是明文传输的Basic认证和Form认证,还是经过摘要加密的Digest认证,都不能很好的解决网络安全问题。Client认证依赖于HTTPS,因此是Java EE安全规范中安全性最高的一种认证方式。使用Client认证需要在web.xml中配置如下:

    CLIENT-CERT


HTTPS通道相关知识以及如何在tomcat配置HTTPS通道证书以及信任证书读者可自行Google,下面重点分析tomcat证书与安全域的关系。

由于Client认证依赖于HTTPS,如果对相应资源的请求不在HTTPS通道上,tomcat就无法获取到客户端证书,也就无法通过证书进一步对用户身份进行认证。此时,浏览器获得的响应如下图所示:


Tomcat安全域实现细节分析_java



Tomcat在请求流程处理中已经将证书解析并保存在了Request对象的"javax.servlet.request.X509Certificate"属性中,证书类使用了JDK提供的抽象类java.security.cert.X509Certificate,并使用X509Certificate.getSubjectDN().getName()方法作为安全域的默认登录用户名。登录用户名是通过org.apache.catalina.realm.X509UsernameRetriever接口的实现类org.apache.catalina.realm.X509SubjectDnRetriever获取的,因此如果不想要在安全域的用户名列表里添加过于复杂的形如“CN=localhost, OU=apache, O=apache, L=beijing, ST=bj, C=cn”的用户名,可以定制自己的X509UsernameRetriever实现类。


因为用户证书不会带有密码信息,而证书本身就已经能够表示用户身份,所以在接下来的认证中,只需要判断当前通过证书获取的用户名是否在安全域名单中就可以了。如果安全域名单中存在该证书用户,则可以认为认证通过,可以继续进行下面的授权工作。


五、授权


认证工作完成的是证明发起当前请求的用户是其所声称的用户,简单的可以解释为只要提供了正确的凭证(用户名、密码或证书),则认为是该用户在请求资源。而接下来的授权工作则需要判断该用户是否有权限访问该资源。通过在web.xml中配置如下参数,决定哪些用户可以访问相关资源:


前面说了,Servlet规范除了规定了role-name匹配外,也对通配符“*”做了定义:“*”表示web.xml中定义的所有用户,“**”则表示所有通过了认证的用户。同时对于role-name为空的情况,则任何用户都不能访问相应的资源。针对上述几种特殊的情况,tomcat在授权时按续进行了处理:


1.判断“constraint.getAuthenticatedUsers() && principal != null”,如果配置了“**”,且通过了认证,设标志位为true;否则进行下一步。


2.判断“roles.length == 0 && !constraint.getAllRoles() &&!constraint.getAuthenticatedUsers()“,如果没有配置role-name,且没有配置“*”和“**”,设标志位为false;否则进行下一步。


3.判断“principal == null”,如果没有通过授权,设标志位为false,否则进行下一步。


4.比对当前用户角色与配置文件中的角色,如果存在匹配角色,设标志位为true,进行下一步。

没有通过上述授权?没关系,还有通配符“*”没有充分派上用场。针对“*”通配符,Tomcat做出了比servlet规范更加贴合实际应用场景的扩展,分为三种情形:一,严格按照规范使用,“*”只表示web-app/security-role/role-name节点下的所有用户;二,“*”表示任何通过了认证的用户,该用法在实际应用中使用的可能更多一些,面对用户量大且复杂的应用场景,将所有用户角色添加到web.xml中缺乏可行性和易维护性,此处实现与规范定义的“**”功能相同,笔者认为其现实意义就是使通配符“*”的含义更加符合开发人员的使用习惯;三,上述两种方法的折中,如果配置了web-app/security-roles下的角色,则按第一种方法使用,否则按照第二种方法使用。因此,授权流程继续:


5.判断“allRolesMode == AllRolesMode.AUTH_ONLY_MODE”,只需认证即可,设标志位为“true”,否则进行下一步。


6.判断“roles.length == 0 && allRolesMode == AllRolesMode.STRICT_AUTH_ONLY_MODE”,没有配置web-app/security-roles节点下的角色,只需认证即可,设标志位为true。


7.根据标志位返回403 Forbidden 响应或者返回用户请求资源。


六、安全域实现


前面简单介绍了安全域的相关接口和抽象类,在具体的安全域实现时只需根据相应的逻辑获取或者比对认证信息,读者对此应该有了大致的了解。例如,JDBC安全域在进行认证时,通过JDBC连接查询用户名对应的密码,然后与客户端密码进行比对,返回认证结果。下面介绍一下Tomcat中很有实用价值的用于防止暴力破解用户信息的org.apache.catalina.realm.LockOutRealm以及跟另一规范相关的org.apache.catalina.realm.JAASRealm。

LockOutRealm的主要工作是对先于认证工作对用户名进行校验,而真正的认证工作还依赖于其他的安全域实现,所以LockOutRealm继承了父类org.apache.catalina.realm.CombinedRealm,LockOutRealm后续的认证工作就交由CombinedRealm中的其他安全域进行了。

在了解LockOutRealm的实现之前,可以构思一下要实现防止暴力破解需要哪些功能:


1.首先需要一个List或者Map,用于存储登录失败的用户名称和相关信息,而这个List或者Map又不能无限大,必须是有界的,否则会导致严重的内存泄露,当然如果不受在tomcat的jvm实现的限制话,生产条件下我们可能会使用Redis。


2.需要定义用户锁定时的登录失败次数。


3.需要定义用户解锁时长。


4.存储登录失败用户的List或者Map由于有界,就有可能存在撑满的情况,需定义此时的操作规则。


完成上述几个功能点,一个比较完善的防暴力破解安全域就形成了。

下面重点看一下tomcat存储失败用户的实现:

LinkedHashMap(, ,
) {
= ;
removeEldestEntry(//重写方法,防止内存溢出
            Map.Entry eldest) {
(size() > ) {
timeInCache = (System.() -
                    eldest.getValue().getLastFailureTime())/;

(timeInCache < ) {//没到时间就被移出黑名单了,要打个日志
.warn(.getString(,
                        eldest.getKey(), Long.(timeInCache)));
            }
;
        }
;
    }
};

Tomcat使用了常用的LinkedHashMap存储登录失败的用户,并且重写了removeEldestEntry方法,在JDK的实现中改方法是始终返回false的:


removeEldestEntry(Map.Entry eldest) {
;
}


而removeEldestEntry方法在每一次调用put或者putAll方法向Map中添加entry的时候都会被调用,通过该方法的返回值,判断是否需要将最“老”的一个entry删除。可见JDK为LinkedHashMap提供了一种灵活的控制Map大小的方法,而tomcat则利用了LinkedHashMap的这一特性。而后,将登陆失败的用户存储在Map中,并通过记录当前时间,在以后的登陆中判断是否对当前用户放行就很好实现了:


registerAuthFailure(String username) {
    LockRecord lockRecord = ;
() {
(!.containsKey(username)) {
            lockRecord = LockRecord();
.put(username, lockRecord);    //第一次登录失败,加入黑名单
        } {
            lockRecord = .get(username);
(lockRecord.getFailures() >= &&
                    ((System.() -
                            lockRecord.getLastFailureTime())/)
                            > ) {
lockRecord.setFailures();    //距离上次失败时间久远,重置失败次数
            }
        }
    }
    lockRecord.registerFailure();    //失败次数自增,失败时间更新,用于下次判断
}


JAASRealm是Tomcat提供的最为开放的安全域,采用了JAAS规范相关的类和接口,因为JAAS安全域中进行实际认证的类需要用户按照使用场景进行实现,因此JAAS安全域也被称为自定义安全域。


JAAS规范全称为Java Authentication and Authorization Service,是一套可插拔的认证授权机制,Tomcat实现的现有安全域都可以通过JAAS安全域进行实现。JAAS安全域的认证流程如下:


1. 使用当前配置创建一个LoginContext的实例,配置包括LoginModule的名称,用于传递认证信息的JAASCallbackHandler实例,configFile配置。


2. 通过LoginContext.login()方法进行验证。


3. 如果没有异常且认证信息不为空,则认证成功;否则捕获异常,认证失败。

关键代码如下:

protected Principal authenticate(String username,

CallbackHandler callbackHandler) {

……

try {

Configuration config = getConfig();

loginContext = new LoginContext(//构造LoginContext

appName, null, callbackHandler, config);

}

……

try {

loginContext.login();//调用login方法进行认证

subject = loginContext.getSubject();//获取认证信息,为空则认证失败

if (subject == null) {

if( log.isDebugEnabled())

log.debug(sm.getString("jaasRealm.failedLogin", username));

return (null);

}

}

……

}


这个流程是不是看起来超简单?只需按自己的需求实现LoginModule,JAAS安全域的认证工作便如行云流水般了。LoginModule的实现可参照相关文档或JDK中的源码,默认JDK已经提供了6种实现哦。



七、总结一下


本文重点介绍了Tomcat安全域部分的实现,结合部署描述符web.xml中的配置,讲解了Tomcat安全域对认证、授权工作的流程处理。文章中对HTTP的四种认证方式进行了较大篇幅的讲解,在授权部分也详细讲解了Tomcat的处理流程。最后在安全域实现部分,重点介绍了两种特殊的安全域,并简单分析了JAAS规范的相关内容,就是这样啦。