制作用户帐户体系时,使用 LDAP 有时是一个好的选择,因为它读性能优异,并且帐户体系下写入数据的情况较少。最近我就遇到了需要集成 LDAP 用户体系的场景。
还是先打开 Idea,建立一个 Ktor 工程,加入 LDAP 相关的配置,这里需要大家搞明白的是,连接 LDAP 就和连接数据库一样,是需要提供 LDAP 帐号密码的,而不是简单的一个 URL 连上后就可以直接调用帐号密码登录,这一点在网上很多文章内都没提及(或是故意被忽略了)。
配置内容写在 application.conf
内,写法如下:
ldap {
url = "ldap://"
baseDC = "ou=XXX,dc=XXX,dc=XXX"
factory = "com.sun.jndi.ldap.LdapCtxFactory"
authLevel = "simple"
principal = "cn=XXX,dc=XXX,dc=XXX"
password = ""
timeout = 3000
}
这里的 ou
, dc
, cn
等值均是在 LDAP 内进行设定的,其中 cn
即是用于连接 LDAP 的帐号。
然后我们可以写一个简单的类,来连接 LDAP:
class LDAPConnection(private val app: Application) {
private val baseDC: String = app.config("ktor.ldap.baseDC")
private val ctx: DirContext
init {
ctx = InitialLdapContext(Hashtable().apply {
this[Context.PROVIDER_URL] = app.config("ktor.ldap.url")
this[Context.SECURITY_PRINCIPAL] = app.config("ktor.ldap.principal")
this[Context.SECURITY_CREDENTIALS] = app.config("ktor.ldap.password")
this[Context.SECURITY_AUTHENTICATION] = app.config("ktor.ldap.authLevel")
this[Context.INITIAL_CONTEXT_FACTORY] = app.config("ktor.ldap.factory")
this["com.sun.jndi.ldap.connect.timeout"] = app.config("ktor.ldap.timeout")
}, null)
}
}
此时就得到了一个 DirContext
的实例,下面对于登录用户的查询也是由此实例进行的。
那么就直接写一个登录函数吧:
fun login(account: String, password: String): Pair {
val nameList = ctx.search(baseDC, "uid=$account", SearchControls().apply { searchScope = SearchControls.SUBTREE_SCOPE }).toList()
if (nameList.isEmpty()) return "" to ""
val name = nameList[0]
val realName = name.attributes.get("cn")[0] as String
val pwd = name.attributes.get("userpassword")[0] as ByteArray
val pwdStr = pwd.map { it.toChar().toString() }.reduce { acc, s -> "$acc$s" }
val pwdCorrect = verifySHA(pwdStr, password)
return if (pwdCorrect) account to realName else "" to ""
}
对上面这段代码简单的作一个解释,首先从 DirContext
内查询到指定 account
对应的用户,并组装成列表。然后判断列表是否为空,如果是空的,即表示没有找到用户,所以 account
是错误的。当 account
正确,可以找到条目时,获取该用户的真实姓名以及密码,最后对密码进行校验,并判断登录是否成功。
关键代码解析:
val pwdStr = pwd.map { it.toChar().toString() }.reduce { acc, s -> "$acc$s" }
由于从 LDAP 内读取的密码内容是 ByteArray
,并且在校验时是按该 ByteArray 按位转为十六进制字符串,所以在此先进行对该字符串的拼接。
verifySHA
用于实现密码的校验,具体实现如下:
private fun verifySHA(ldapPwd: String, inputPwd: String): Boolean {
val tmp = when {
ldapPwd.startsWith("{SSHA}") -> ldapPwd.substring(6)
ldapPwd.startsWith("{SHA}") -> ldapPwd.substring(5)
else -> ldapPwd
}
val bLdapPwd = Base64.getDecoder().decode(tmp)
val (shaCode, salt) = if (bLdapPwd.size <= 20) {
bLdapPwd to ByteArray(0)
} else {
ByteArray(20) { bLdapPwd[it] } to ByteArray(bLdapPwd.size - 20) { bLdapPwd[20 + it] }
}
val bInputPwd = MessageDigest.getInstance("SHA-1").run {
update(inputPwd.toByteArray())
update(salt)
digest()
}
return MessageDigest.isEqual(shaCode, bInputPwd)
}
LDAP 的密码采用 SSHA 算法,其实就是在 SHA 算法上,加入了一个 salt
校验段,通过上面的算法就可以计算出来了。
最后就可以实现登录了,写一个简单的路由就完事了:
fun Routing.userRouting() {
post("/login") {
val params = call.requestParameters()
val account = params["account"] ?: ""
val password = params["password"] ?: ""
val (acc, realName) = application.ldap.login(account, password)
if (acc == "" || realName == "") {
localSession.account = ""
localSession.realName = ""
call.respondText { COMMON_FAILED }
} else {
localSession.account = acc
localSession.realName = realName
call.respondText { COMMON_SUCC }
}
}
}