[Ktor] 实现LDAP登录

制作用户帐户体系时,使用 LDAP 有时是一个好的选择,因为它读性能优异,并且帐户体系下写入数据的情况较少。最近我就遇到了需要集成 LDAP 用户体系的场景。

还是先打开 Idea,建立一个 Ktor 工程,加入 LDAP 相关的配置,这里需要大家搞明白的是,连接 LDAP 就和连接数据库一样,是需要提供 LDAP 帐号密码的,而不是简单的一个 URL 连上后就可以直接调用帐号密码登录,这一点在网上很多文章内都没提及(或是故意被忽略了)。

配置内容写在 application.conf 内,写法如下:

ldap {
        url = "ldap://<ip or domain>"
        baseDC = "ou=XXX,dc=XXX,dc=XXX"
        factory = "com.sun.jndi.ldap.LdapCtxFactory"
        authLevel = "simple"
        principal = "cn=XXX,dc=XXX,dc=XXX"
        password = "<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<String, String>().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<String, String> {
        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 }
        }
    }
}

推荐阅读更多精彩内容