本文简要介绍Cloud Foundry的用户认证过程,以及相关项目组件,并介绍同企业LDAP认证集成的方法。碍于篇幅,本文将众多代码和配置文件信息用github链接的方式给出,具体到行,请辅助查阅。
Cloud Foundry 作为如今炙手可热的开源项目,借助其可以方便搭建企业内部PaaS平台,然而在集成时首先遇到的问题就是同企业内部系统中的授权系统(如LDAP)集成。与认证活动的相关的组件,主要有以下4个:
认证系统的发展史随着Cloud Foundry的发展逐渐成熟起来的。
起初,Cloud Foundry的认证系统也从最初的在Cloud Controller组件中,用户将用户名和密码存在Cloud Controller 的数据库中,当登录时提供用户名和密码,并获取一个token,后续操作需要提供token进行验证后方能进行,如图所示:
进而增加了UAA (User Account and Authentication) 组件和ACM (Access Control Management)专门用于用户认证和访问控制(后者本文暂不涉及),UAA采用多种开放的标准协议,支持OAuth验证,TOKEN采用标准的JWT(JSON Web TOKEN)格式封装,并对外开放SCIM(System for Cross-domain Identity Management)接口进行用户操作。在此基础上,需要授权与认证的组件就相当于OAuth验证的活动参与者,VMC作为客户,Cloud Controller作为第三方客户端,UAA则扮演服务提供方的角色,同时基于Cloud Foundry开发的系统也可以通过OAuth协议请求UAA授权和认证。此时结构如图所示:
然而,在私用云中往往涉及外部认证的场景,一方面来自企业认证(LDAP等方法)的用户在登录时需要能够进行外部验证以保证用户身份,另一方面用户在访问cloud foundry时需要能够继续通过UAA进行认证,为此又加入了login-server组件来支持外部授权,同时作为UAA的一个特殊客户端,可以申请UAA的TOKEN。最终形成了一个支持扩展的认证组件集合,如图所示。
如上图所示,认证过程参与者众多,登录的流程简单来说包含图中所示的10个步骤:
vmc -> cloud_controller : GET /info
在返回的JSON信息中包括"authorization_endpoint": "http://login.cf.com"
,vmc会根据此信息申请验证
vmc -> login-server: GET /login
此处是请求login-server获取需要验证的信息的提示,如
"prompts": { "username": [ "text", "Email" ], "password": [ "password", "Password" ] }
该提示信息的处理逻辑在org.cloudfoundry.identity.uaa.login.RemoteUaaController
中,根据prompts
属性,首先选取prompts
属性,如果没有被设定,则请求UAA uaaBaseUrl(配置项中的uaa.url
见代码),如果请求失败,则采用默认值Email+Password,相关代码见RemoteUaaController#getLoginInfo。如果要修改提示信息,可以在spring-servlet.xml中注入属性值,或调整最后默认值。
vmc -> login-server : POST /oauth/authorize?client_id=vmc&response_type=token
vmc根据先前获取的prompts信息提示用户输入用户名/密码,在请求body中包括了类似验证信息credentials={"username":"foo","password":"bar"}
,对于vmc-0.5.1及之后的版本包含的验证信息为username=foo&password=bar&source=credentials
,处理过程和前者一致
login-server -> ldap
login-server对ldap和OAuth的请求和验证是通过Spring Security实现的。对应的filter是AuthzAuthenticationFilter(spring配置),会根据spring_profile确定是直接使用uaa的oauth验证还是请求外部验证。对于ldap类型的验证,将采用UsernamePasswordExtractingAuthenticationManager进行(spring配置代码),实际还是通过org.springframework.security.ldap.authentication.LdapAuthenticationProvider
代理来进行实际的LDAP验证。如果验证成功,进入RemoteUaaController#startAuthorization进行响应。
login-server -> uaa : POST /oauth/authorize?client_id=vmc&response_type=token&source=login&username=foo
请求的消息体在RemoteUaaController#startAuthorization中组装,通过org.springframework.security.oauth2.client.OAuth2RestTemplate
发送请求,设置在spring配置中.HTTP body中包含
[{response_type=[token], redirect_uri=[https://uaa.cloudfoundry.com/redirect/vmc], client_id=[vmc], source=[login], username=[foo]}]
表示源请求来自vmc,由login-server向uaa请求验证,用户名为foo
uaa -> uaa-db -> login-server
uaa也是利用Spring Security实现的认证和授权功能。请求中包含source=login
向uaa声明来源来自login-server,被声明在login-server-security.xml中的loginAuthorizeRequestMatcher命中,这里声明了两个filter
<custom-filter ref="oauthResourceAuthenticationFilter" position="PRE_AUTH_FILTER" />
<custom-filter ref="loginAuthenticationFilter" position="FORM_LOGIN_FILTER" />
前者声明为org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint的filter,在uaa.yml中配置了Login-server应具有的权限操作_oauth.login_
后者是AuthzAuthenticationFilter的一个实例,会从request中抽取用户的username,将实际的认证操作代理给loginAuthenticationMgr中,声明为LoginAuthenticationManager的一个实例,根据spring配置,传入两个重要参数,其中”addNewAccounts“用于判断是否在用户不存在时根据Login传入的用户信息新建用户,对应uaa.yml
中的login.addnew
的值,”userDatabase“则根据配置文件中的database信息代理uaa-db的操作。
在LoginAuthenticationManager#authenticate中,代码如下
@Override
public Authentication authenticate(Authentication request) throws AuthenticationException {
if (!(request instanceof AuthzAuthenticationRequest)) {
logger.debug("Cannot process request of type: " + request.getClass().getName());
return null;
}
AuthzAuthenticationRequest req = (AuthzAuthenticationRequest) request;
Map<String, String> info = req.getInfo();
logger.debug("Processing authentication request for " + req.getName());
SecurityContext context = SecurityContextHolder.getContext();
if (context.getAuthentication() instanceof OAuth2Authentication) {
OAuth2Authentication authentication = (OAuth2Authentication) context.getAuthentication();
if (authentication.isClientOnly()) {
UaaUser user = getUser(req, info);
try {
user = userDatabase.retrieveUserByName(user.getUsername());
}
catch (UsernameNotFoundException e) {
// Not necessarily fatal
if (addNewAccounts) {
// Register new users automatically
publish(new NewUserAuthenticatedEvent(user));
try {
user = userDatabase.retrieveUserByName(user.getUsername());
}
catch (UsernameNotFoundException ex) {
throw new BadCredentialsException("Bad credentials");
}
}
else {
throw new BadCredentialsException("Bad credentials");
}
}
Authentication success = new UaaAuthentication(new UaaPrincipal(user), user.getAuthorities(),
(UaaAuthenticationDetails) req.getDetails());
publish(new UserAuthenticationSuccessEvent(user, success));
return success;
}
}
logger.debug("Did not locate login credentials");
return null;
}
代码很简单,首先验证传入请求的类型是否是AuthzAuthenticationRequest并确认是OAuth2类型的验证,根据请求中包含的user信息,要求name和email字段至少二者有其一,如果name为null,则将email作为name,反之如果email为null,则根据name中是否包含@进行判断,如果包含,email=name,否则email=name@unknown.org,而givenName和familyName如果不存在,则分别取email字段的@前后两部分,具体代码见此。之后查询uaa-db中是否包含username=foo的用户,如果找到则返回验证成功。否则如果允许添加新用户,则发布新增用户的事件,由ScimUserBootstrap负责处理事件,新增用户,当用户添加成功后返回验证成功,否则验证失败。
简单介绍一下UAA中的事件机制,这里新增用户和记录Log等操作都基于Spring的事件机制,uaa项目内部总共有三类事件,AbstractUaaEvent + AuthenticationFailureBadCredentialsEvent (Spring的事件,UAA监听用于发布AbstractUaaEvent的事件实例以便log) + NewUserAuthenticatedEvent ,分别对应三个Listener AuditListener + BadCredentialsListener + ScimUserBootstrap。SCIM在提供用户操作的REST标准接口之外,也监听新建用户的事件。其中AbstractUaaEvent主要利用JdbcFailedLoginCountingAuditService和LoggingAuditService,前者监听UserAuthenticationSuccess/PasswordChangeSuccess/UserAuthenticationFailure当用户登录后修改密码或登录失败时操作sec_audit表删除认证信息,后者则进行log的管理和统计记录等功能,NewUserAuthenticatedEvent则仅仅用户新建用户。
当通过这些Filters验证后,由org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.authorize进行一番查询操作后返回token信息。
login-server -> vmc ->.vmc
login-server将token返回给vmc, vmc将其记录在~/.vmc/tokens.yml中。除token外还包括token类型、超时时间和JTI(JSON web Token Id)
token_type=bearer&expires_in=604799&jti=1815ccfe-68a4-4d1d-a16a-2eff55622002
vmc -> cloud_controller : GET /apps
当用户对cloud controller进行操作时(以/apps请求为例),vmc在HTTP HEAD中包含token信息
authorization : bearer [tokens]
cloud_contorller -> uaa : GET /token_key
cloud controller是一个典型的ROR工程,在所有的Controller都继承自ApplicationController,其中的before_filter :fetch_user_from_token
将验证用户的TOKEN,首先需要解码token
cloud_contorller -> cc-db -> vmc
然后查询cc-db确定用户,查询token中包含的用户名是否在cc-db中存在,如果存在继续由对应请求的Controller处理,如 GET /apps由AppsController处理,具体的路由规则可以在config/routes.rb中查看
这里存在一个问题,当用户是通过vmc register
方法注册用户时,会请求 POST /users 来创建用户,在UsersController中会根据uaa的配置在uaa和cc-db中创建用户,之后登录时能够通过uaa验证,发送其他请求时ApplicationController可以在cc-db中查找到用户,因而请求可以正常进行。
然而如果用户是从LDAP导入到UAA中去的,省去了注册环节,用户是在login-server向UAA请求token时加入uaa-db的,但cc-db中并没有该用户的数据,在这一步根据email查找用户时会失败,所以返回401错误。同时这里cloud controller查找用户并没有特殊的含义,只是记录用户访问cloud controller的时间(当使用uaa时cc-db的active字段都是false),并不通过该记录验证任何消息。
因此我们进行了如下的代码调整。
相关的代码调整增加对应本人fork cloud_controller的repo中,https://github.com/TieWei/cloud_controller/commit/49fc960330dc881adc199021b33c9c83d25fd85e
if (!user_email.nil?)
CloudController.logger.debug("user_email decoded from token is #{user_email.inspect}")
@current_user = ::User.find_by_email(user_email)
if @current_user.nil? && uaa_enabled?
CloudController.logger.debug("#{user_email.inspect} from uaa is not in CloudController DB, Try to create a proxy one")
user = ::User.new :email => user_email
# the password is encrypt with (user_email + current time)
user.set_and_encrypt_password(user_email + Time.now.to_s)
if user.save
@current_user = ::User.find_by_email(user_email)
CloudController.logger.info("proxy user #{user_email.inspect} from uaa is added into CloudController DB")
else
@current_user = nil
CloudController.logger.warn("proxy user #{user_email.inspect} from uaa is not added into CloudController DB")
end
end
end
如果用户不存在,且采用UAA的方式进行验证,则根据用户的email和当前时间生成一个代理用户(只用于让cloud controller知道该用户存在),并存入cc-db中。
实现虽然略显dirty,但是总归是能work了。
另外,如果选用vmc-0.5.1版本的客户端,请求login-server时附带的body中包含的信息是username=foo&password=bar&source=credentials
,这里在login-server请求处理/oauth/authorize
时会有一点安全隐患 – login-server会将用户的密码存入log (log级别是debug时)并发送给uaa,当采用外部认证的场景时,用户的密码或许会因为login-server的log而被利用。此处代码全部代码在RemoteUaaController中,关键片段如下:
if (principal != null) {
map.set("source", "login");
map.setAll(getLoginCredentials(principal));
map.remove("credentials"); // legacy vmc might break otherwise
}
当请求是credentials={"username":"foo","password":"bar"}
时,用户密码信息会被删除,然而0.5.1版本时则不会删除,解决办法很简单,加一行
map.remove("password");
即可。
使用cloud controller的master分支,最新版加入了login-server的支持,关键代码在此
使用 cloudfoundry-identity-uaa-1.4.1.war ,理论上login-server的spring_profile中有ldap即可,关键代码在此
使用 cloudfoundry-login-server-1.2.1.war,uaa有login-server-security.xml响应来自login-server的请求即可,关键代码在此。
编辑login.yml
,设定如下
spring_profiles: ldap
ldap:
base:
url: 'ldap://your.domain.com:389/dc=domain,dc=com'
userDnPattern: 'CN={0},ou=Employees, ou=Users'
PS: 默认的userDnPattern是uid={0},ou=people
,如果需要调整(如上设定),需编辑此处代码为
<value>${ldap.base.userDnPattern:uid={0},ou=people}</value>
如果需要从LDAP向UAA导入用户,需要编辑uaa.yml
,设定如下
login
addnew: true
这样,Cloud Foundry就可以正确将用户登录信息向LDAP请求验证。
OAuth 2 - token based authentication for web applications and APIs. Defines the client software as a role. Separates issuing tokens from how you use a token. Token issuance is defined both for browsers and for REST clients using a username/password. Token format is not defined by OAuth2, but one proposed standard format is JWT.
JWT - JSON Web Tokens, an upcoming standard format for structured tokens (containing data) which are integrity protected and optionally encrypted.
SCIM - cross-domain user account creation and management. REST API for CRUD operations around user accounts