这篇随笔谈一谈如何在Java环境下利用Unix/Linux的用户名和密码对用户的权限作出过滤。为方便大家学习交流,本文中给出了源代码,借此抛砖引玉,欢迎大家对这个简单的登录模型做出改进或者设计出自己的技术方案。
由标题我们不难看出,与本文相关的知识点主要有3个:
1 JAAS这个解耦设计的多层验证方法(1.4后已归入Java核心库中)
2 应用JNI访问底层代码,及JNI中简单的类型匹配
3 在shadow模式下,Unix/Linux系统的用户验证
首先聊聊JAAS,顾名思义,JAAS由认证和授权两个主要组件组成。JAAS的交互点在LoginContext这个类里面,在构造LoginContext时,常常需要指定两个内容(还有其他默认的构造子重载形式),这两个内容是LoginModule的名字和Subject。
为使描述直观,先给出代码如下:
<!---->
import
java.security.Principal;
import
javax.security.auth.Subject;
import
javax.security.auth.login.LoginContext;
import
javax.security.auth.login.LoginException;
public
class
MyLogin {
public
MyLogin(){
Subject subject
=
new
Subject();
subject.getPrincipals().add(
new
Principal(){
public
String getName() {
return
"
yiyang
"
;
}
});
subject.getPrivateCredentials().add(
"
sh0w00f
"
);
try
{
LoginContext lc
=
new
LoginContext(
"
mylogin
"
,subject);
lc.login();
}
catch
(LoginException e) {
e.printStackTrace();
}
}
public
static
void
main(String[] args){
new
MyLogin();
}
}
先说LoginModule的名字,在系统属性java.security.auth.login.config中(或者在jre/lib/security/java.security)指定了LoginModule的配置文件,LoginModule在Java中被定义成一个接口,这个地方应用了面向对象的依赖倒置原则,使用了类似JDBC这样的SPI的机制来定制认证策略。根据用户在构造LoginContext时指定的LoginModule的名字Java在系统环境中找到对应的LoginModule配置文件,这个配置文件的最简单形式如下:
mylogin {
UnixLoginModule required
};
这时当我们应用mylogin这个名字实例化LoginContext的时候,系统就会自动的找到UnixLoginModule这个LoginModule去处理。后面的required是一个flag标志,表示此次验证的关键性,有4个值可以选择,当选定required时则表示必须成功,由此我们就可以定义一系列的验证,形成一个过滤层,并根据不同的flag得出最后的结论,比如:我们可能希望我们的web用户只要通过数据库的验证,而不必通过操作系统的验证。
此外我们还可以设置一些其他的参数(以key=value的形式),而且实际上验证是两阶段提交的,并且可以通过回调函数的形式在具体的认证平台上做一些个性化Context设置。对这些JAAS细节感兴趣的朋友可以读相应的JAAS文档规范。
再说第二个参数Subject,这个主题封装了用户需要验证的信息,主要包括principal和公钥私钥两部分,详细的设置方法可以参考上面的代码。
lc.login返回了一个true或false表示了这次的验证是否成功。
当一个Subject成功login后,就可以通过这个Subject做一些特许的动作Subject.doAs,这些动作根据Subject中principal的不同在com.sun.security.auth.PolicyFile指定的配置文件做了定义,这部分是属于JAAS授权的内容,因为在我们的程序中暂时用不到,所以不做详细讨论了,我们仅仅根据login返回的true或false来决定用户是否可以登录我们的系统即可。
OK,说到这里,我们给出UnixLoginModule的实现代码:
代码中的各个方法是LoginModule所定义的必须实现的方法。注意到代码中,我们应用了PasswdCheck.check(this.usr, this.passwd)来做最后的验证,这是因为对Unix系统用户的验证必须调用系统API才可以,而系统API是以C的形式提供的,因此我们需要借助JNI。现在我们看看PasswdCheck这个类:
<!---->
public
class
PasswdCheck {
static
{
System.out.println(System.getProperty(
"
java.library.path
"
));
Runtime.getRuntime().load(
"
/home/yiyang/eclipse/workspace/JAAS/libpasswd.so
"
);
}
public
native
static
int
check(String usr, String passwd);
}
在这里用到了JNI来调用底层的用户名密码验证方案,为此我们需要构造出libpasswd.so这个库。
一步一步来:
1 用javah生成JNI的头文件:
javah PasswdCheck
得到如下代码:
<!---->
/*
DO NOT EDIT THIS FILE - it is machine generated
*/
#include
<
jni.h
>
/*
Header for class PasswdCheck
*/
#ifndef _Included_PasswdCheck
#define _Included_PasswdCheck
#ifdef __cplusplus
extern
"
C
"
{
#endif
/*
* Class: PasswdCheck
* Method: check
* Signature: (Ljava/lang/String;Ljava/lang/String;)I
*/
JNIEXPORT jint JNICALL Java_PasswdCheck_check
(JNIEnv
*
, jclass, jstring, jstring);
#ifdef __cplusplus
}
#endif
#endif
现在把头文件中定义的函数实现:
在jni.h这个头文件中定义了jni和c之间的类型关系,通过分析,用户名密码的字符串可以通过如下函数获取:
char * username =(*env)->GetStringUTFChars(env, usr, NULL);
char * password =(*env)->GetStringUTFChars(env, psw, NULL);
其他的简单型别很多被直接typedef了。
我们真对生成的头文件,实现如下:
<!---->
#include
"
PasswdCheck.h
"
//
生成的头文件
#include
"
pwd.h
"
//
getspnam
#include
"
stdio.h
"
#include
"
unistd.h
"
//
crypt必需
#include
"
shadow.h
"
//
getspnam
#define _XOPEN_SOURCE
//
crypt必需
JNIEXPORT jint JNICALL Java_PasswdCheck_check
(JNIEnv
*
env, jclass jc, jstring usr, jstring psw){
char
*
username
=
(
*
env)
->
GetStringUTFChars(env, usr, NULL);
char
*
password
=
(
*
env)
->
GetStringUTFChars(env, psw, NULL);
struct spwd
*
sp
=
getspnam(username);
char
*
p;
p
=
crypt(password, sp
->
sp_pwdp);
return
strcmp(sp
->
sp_pwdp,p);
}
上面的实现中的结构体spwd定义如下:
<!---->
struct spwd {
char
*
sp_namp;
/*
user login name
*/
char
*
sp_pwdp;
/*
encrypted password
*/
long
int
sp_lstchg;
/*
last password change
*/
long
int
sp_min;
/*
days until change allowed.
*/
long
int
sp_max;
/*
days before change required
*/
long
int
sp_warn;
/*
days warning for expiration
*/
long
int
sp_inact;
/*
days before account inactive
*/
long
int
sp_expire;
/*
date when account expires
*/
unsigned
long
int
sp_flag;
/*
reserved for future use
*/
}
getspnam函数可以获取一个被单向加密后的密码(有4种可选加密形式)
crypt函数把我们的原始密码按相同密钥和算法加密后,即可通过比较加密后字符串的形式获取是否密码正确的信息。需要主义的是只有在使用shadow机制的系统中才应用getspnam,如果/etc/passwd直接描述了密码,则可以通过函数getpwnam来获取(或者直接解析文本),这时一般采用的是13位的DES加密,问题变得简单。
在编写完实现后通过命令
gcc -lcrypt PasswdCheck.c -shared -o libpasswd.so
进行编译,把这个库cp到/usr/lib(或其他ld_library_path)下就可以用平台相关的方式System.loadLibrary加载,否则就要用系统绝对路径名了(利用System.load)
因为只有root能获取到getspnam,所以我们只能这样来执行我们的java进行验证,sudo java MyLogin (yiyang is in group wheel defined in /etc/sudoers)
否则将得到如下出错信息:
<!---->
#
# An unexpected error has been detected by Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0xb7ef95ad, pid=9726, tid=3084450720
#
# Java VM: Java HotSpot(TM) Client VM (1.6.0_03-b05 mixed mode)
# Problematic frame:
# C [libpasswd.so+0x5ad] Java_PasswdCheck_check+0x61
#
# An error report file with more information is saved as hs_err_pid9726.log
#
# If you would like to submit a bug report, please visit:
# http://java.sun.com/webapps/bugreport/crash.jsp
#
当然,如果我们不用JNI,而采用Web Services(具体方法见笔者上一篇blog: http://yangyi.blogjava.net)那么可以通过set suid的形式定制一个进程了(不过这已经是另一个话题),毕竟用root启动tomcat不是很让人放心:
chown root XXX
chmod +s XXX
Anyway, 至此通过JAAS认证Unix用户的基本思路就描述完了,读者可以填补其中的漏洞并把JAAS用到自己的工作场景中。