目录

杀鸡焉用牛刀?谈谈如何自己实现Api接口鉴权

现在前后端分离的趋势越来越明显了,后端正在逐渐开始做纯RESTful性质的服务,关于接口鉴权这个问题就是老生常谈了,目前常用的Spring Security + Jwt, Shiro + Jwt等极其繁琐的配置,仅仅为了实现一个小小的登陆功能,显得过于笨重繁杂了,本文来探讨一下如何自己实现一个简单优雅的JWT鉴权方案

总体方案

从一切从简的角度考虑,要对接口实现鉴权,最行之有效的可能就是使用HandlerInterceptor拦截器了。

  1. 定义注解@RequiredAuthorize/@SkipAuthorize标记类或者函数需要或者跳过鉴权。
  2. 提供接口,使用Jwt签发AccessToken,提供给客户端,作为鉴权的令牌。
  3. 实现HandlerInterceptor,针对注解标记的接口实施鉴权验证。
  4. 实现全局异常拦截器,处理鉴权失败异常,最终以JSON格式返回失败消息给客户端。

关于Jwt缺点及解决方案

jwt的相关说明不再赘述,在网上能找到大量资料,详见官网https://jwt.io
这里说说jwt的主要缺点,后文将逐一解决之.

  1. 无法作废已颁布的令牌。
    所有的认证信息都在JWT中,由于在服务端没有状态,即使你知道了某个JWT被盗取了,你也没有办法将其作废。在JWT过期之前(你绝对应该设置过期时间),你无能为力。
  2. 不易应对数据过期。
    与上一条类似,JWT有点类似缓存,由于无法作废已颁布的令牌,在其过期前,你只能忍受“过期”的数据。

这两个确实是非常致命的,一旦token签发,则不可收回,是一个比较大的安全隐患,那么有哪些解决方案呢?

  1. 尽可能缩短token的有效期,让前端定期刷新token,尽可能的降低风险。( 不考虑 )
  2. 将签发的token在服务端存储一份,校验token时首先检查服务端的token状态。(违背Jwt的设计初衷, 不考虑 )
  3. 后端只存储已签发token的BlackList ,且设有过期时间(比token过期时间略长),校验token时,先校验token本身,再检查是否在BlackList中,后端需要对某个token实施作废,只需要加入BlackList中即可。(妥协的方案,目前看来可行,解决了无法作废已颁布的令牌的问题)

开始实现

按照预定的方案,咱们开始逐一实现。

https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/883ea8bd-ddcd-44d0-a187-4a31bd0802ae.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

引入依赖

1
2
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.auth0:java-jwt:3.10.3")

引入Redis,是为了实现BlackList,其具备过期时间以及高性能等特性很是适合。

定义配置文件

1
2
3
4
5
6
7
@Configuration
@ConfigurationProperties(prefix = "auth.jwt")
class JwtProperties(
        var issuer: String = "DEFAULT_ISSUER",
        var secret: String = "DEFAULT_JWT_SECRET#!@#$%^&*()==",
        var expireAt: Int = 60 * 60 // 1 Hour (单位: 秒)
)

对应配置文件:

1
2
3
4
5
6
auth:
  enabled: true
  jwt:
    issuer: WebApp
    secret: "WebApp#!@#$%^&*()=="
    expire-at: 3600

Jwt基础封装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class JwtPayload {
    companion object {
        fun toPayload(map: Map<String, Any?>): JwtPayload {
            return BeanUtil.mapToBean(map, JwtPayload::class.java, false)
        }
    }

    var uid: Int = -1
    var roles: Array<String> = arrayOf()
    var permissions: Array<String> = arrayOf()

    constructor()
    constructor(uid: Int) {
        this.uid = uid
    }

    constructor(uid: Int, roles: Array<String>) {
        this.uid = uid
        this.roles = roles
    }

    constructor(uid: Int, roles: Array<String>, permissions: Array<String>) {
        this.uid = uid
        this.roles = roles
        this.permissions = permissions
    }

    fun toMap(): Map<String, Any?> {
        return BeanUtil.beanToMap(this)
    }
}

class JwtToken(var accessToken: String, var expireAt: Date)

class JwtValidException(override val message: String?) : RuntimeException()

@Configuration
class JwtConfiguration(private val jwtProperties: JwtProperties) {

    @Bean
    fun defaultJwtAlgorithm(): Algorithm {
        return Algorithm.HMAC256(jwtProperties.secret)
    }

}

@Component
class JwtHelper(
        private val algorithm: Algorithm,
        private val jwtProperties: JwtProperties) {

    companion object {
        private const val PAYLOAD_KEY = "payload"
    }

    fun createToken(payload: JwtPayload): JwtToken {
        val expireAt = DateUtil.date().offset(DateField.SECOND, jwtProperties.expireAt)
        val accessToken = JWT.create().withIssuer(jwtProperties.issuer)
                .withClaim(PAYLOAD_KEY, payload.toMap())
                .withExpiresAt(expireAt)
                .sign(algorithm)
        return JwtToken(accessToken, expireAt)
    }

    fun verify(token: String): Boolean {
        return try {
            JWT.require(algorithm).build().verify(token)
            true
        } catch (e: Throwable) {
            false
        }
    }

    fun parse(token: String): JwtPayload {
        val decodedJWT = JWT.decode(token)
        return JwtPayload.toPayload(decodedJWT.getClaim(PAYLOAD_KEY).asMap())
    }
}

定义JwtRepository

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
interface JwtRepository {
    fun create(payload: JwtPayload): JwtToken
    fun delete(token: String)
    fun parseAndVerify(token: String): JwtPayload
    fun refresh(token: String): JwtToken
}

@Repository
class JwtRedisRepository(
        private val jwtHelper: JwtHelper,
        private val redisHelper: RedisHelper,
        private val jwtProperties: JwtProperties) : JwtRepository {

    companion object {
        // 将Token存储到Redis黑名单(list),视为无效Token,过期时间为Token的两倍
        private fun blackListTokenKey(token: String): String {
            return "BLACK_LIST_ACCESS_TOKEN:${token.md5()}"
        }
    }

    override fun create(payload: JwtPayload): JwtToken {
        return jwtHelper.createToken(payload)
    }

    override fun delete(token: String) {
        val key = blackListTokenKey(token)
        if (!jwtHelper.verify(token)) {
            redisHelper.del(key) // 如果token本身已经过期,则尝试从黑名单移除
        } else {
            // 否则将未过期的token,存入黑名单列表
            redisHelper.set(key, token, (jwtProperties.expireAt * 2).toLong())
        }
    }

    override fun parseAndVerify(token: String): JwtPayload {
        val key = blackListTokenKey(token)
        // 首先验证签名
        if (!jwtHelper.verify(token)) {
            redisHelper.del(key) // 如果token本身已经过期,则尝试从黑名单移除
            throw JwtValidException("令牌无效或已过期")
        }

        // 再验证是否在黑名单中
        if (token == redisHelper.get(key).toString()) {
            throw JwtValidException("令牌已被移除")
        }

        // 开始解析
        return jwtHelper.parse(token)
    }

    override fun refresh(token: String): JwtToken {
        // 解析Token(要求Token为有效的), 得到payload,用于生成新的Token
        val payload = this.parseAndVerify(token)

        // 删除旧的Token
        this.delete(token)

        // 生成新的Token
        return this.create(payload)
    }
}

定义注解

1
2
3
4
5
6
7
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class RequiredAuthorize(
        val hasRole: Array<String> = [],
        val hasPermission: Array<String> = []
)

实现HandlerInterceptor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Configuration
@ConditionalOnProperty(prefix = "auth", name = ["enabled"], havingValue = "true", matchIfMissing = false)
class WebAuthInterceptorConfiguration(private val jwtRepository: JwtRepository) : WebMvcConfigurer {
    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(AuthorizeHandlerInterceptor(jwtRepository))
                .order(1).addPathPatterns("/**")
    }

    class AuthorizeHandlerInterceptor(
            var jwtRepository: JwtRepository) : HandlerInterceptor {

        override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
            if (handler is HandlerMethod) {
                val annotation = handler.getMethodAnnotation(RequiredAuthorize::class.java)
                        ?: return super.preHandle(request, response, handler)

                val accessToken = WebContext.getAccessToken()
                if (accessToken.isNullOrBlank()) {
                    return this.writeUnauthorizedMessage(response, "需要传入令牌")
                }
                try {
                    // 解析并验证Token
                    val payload = jwtRepository.parseAndVerify(accessToken)
                    // 需要验证角色
                    if (!permissionMatch(payload.roles, annotation.hasRole)) {
                        return this.writeUnauthorizedMessage(response, "角色不匹配")
                    }
                    // 需要验证权限
                    if (!permissionMatch(payload.permissions, annotation.hasPermission)) {
                        return this.writeUnauthorizedMessage(response, "没有所需的权限")
                    }
                } catch (e: Throwable) {
                    return this.writeUnauthorizedMessage(response, e.message ?: "")
                }
            }
            return super.preHandle(request, response, handler)
        }

        private fun permissionMatch(self: Array<String>?, need: Array<String>): Boolean {
            if (need.isNullOrEmpty()) {
                return true // 不需要匹配权限
            }

            if (self.isNullOrEmpty()) {
                return false // 匹配失败,Token中不含角色权限信息
            }
            var count = 0;
            for (item in need) {
                if (ArrayUtil.contains(self, item)) {
                    count++
                }
            }
            return count == need.size
        }

        private fun writeUnauthorizedMessage(response: HttpServletResponse, vararg message: String): Boolean {
            var unauthorized: RestError = Errors.UNAUTHORIZED
            if (message.isNotEmpty() && message[0].isNotBlank()) {
                unauthorized = unauthorized.build(message[0], true)
            }
            ServletUtil.write(response, RestMessage.error(unauthorized).toJSONString(), ContentType.JSON.toString())
            return false
        }
    }
}

定义AuthContext

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
class AuthContext(
        private val jwtRepository: JwtRepository) {

    fun getPayload(vararg canBeNull: Boolean): JwtPayload? {
        // canBeNull = null Or canBeNull = false,尝试抛出异常
        val tryThrow = canBeNull.isEmpty() || (canBeNull.isNotEmpty() && !canBeNull[0])
        val accessToken = WebContext.getAccessToken()
        if (accessToken.isNullOrBlank()) {
            if (tryThrow) {
                throw JwtValidException("需要传入令牌")
            }
            return null
        }
        return try {
            jwtRepository.parseAndVerify(accessToken)
        } catch (e: Throwable) {
            if (tryThrow) {
                throw e
            }
            null // 忽略验证异常,返回null
        }
    }

    fun getUserId(vararg canBeNull: Boolean): Int? {
        return getPayload(*canBeNull)?.uid
    }

    fun getRoles(): Array<String>? {
        return getPayload()?.roles
    }

    fun getPermissions(): Array<String>? {
        return getPayload()?.permissions
    }
}

使用

提供登录/刷新令牌/注销接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestController
@Api(tags = ["用户鉴权"])
@RequestMapping("/auth")
class AuthController(
        private val userService: UserService,
        private val jwtRepository: JwtRepository) {

    @ApiOperation("登录")
    @PostMapping("/login")
    fun login(): RestMessage<JwtToken> {
        // TODO:实现验证用户名密码的具体逻辑
        val user = userService.findByUsername();

        // 生成用户Token
        val payload = JwtPayload(uid = userId, roles=user.roles, permissions=user.permissions)
        return RestMessage.result(jwtRepository.create(payload))
    }


    @RequiredAuthorize
    @ApiOperation("刷新令牌(需登录)")
    @PostMapping("/refresh_token")
    fun refreshToken(): RestMessage<JwtToken> {
        val accessToken = WebContext.getAccessToken()
        return RestMessage.result(jwtRepository.refresh(accessToken!!))
    }

    @RequiredAuthorize
    @ApiOperation("注销令牌(需登录)")
    @PostMapping("/logout")
    fun logout(): RestMessage<*> {
        val accessToken = WebContext.getAccessToken()
        return RestMessage.result(jwtRepository.delete(accessToken!!))
    }

}

需要鉴权的接口示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 表示需要登录后才能操作的接口
@RequiredAuthorize
@GetMapping("/testRequiredAuthorize")
fun testRequiredAuthorize(): RestMessage<String> {
    return RestMessage.result("Ok, 你已经进来了, userId=${authContext.getUserId()}")
}

// 表示需要登录后并且用户身份为“Admin”才能操作的接口
@RequiredAuthorize(hasRole = ["Admin"])
@GetMapping("/testRequiredAuthorizeHasRole")
fun testRequiredAuthorizeHasRole(): RestMessage<String> {
    return RestMessage.result("Ok, 你已经进来了, payload=${authContext.getPayload()?.toJSONString()}")
}

// 表示需要登录后,并且用户拥有“CreateUser”权限才能操作的接口
@RequiredAuthorize(hasPermission = ["CreateUser"])
@GetMapping("/testRequiredAuthorizeHasPermission")
fun testRequiredAuthorizeHasPermission(): RestMessage<String> {
    return RestMessage.result("Ok, 你已经进来了, payload=${authContext.getPayload()?.toJSONString()}")
}

测试

调用登录接口拿到token

https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/b13c61d8-ee40-4cb6-9e68-4efbcf37a2f7.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

使用token调用对应的接口
  1. 不传入token的情况:
    https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/6389ed3a-d072-4865-8baf-a244593f6c02.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

  2. 传入正确的token:
    https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/89cf89ef-b236-42c5-856f-f7706cb5e4c4.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

  3. 传入的token用户角色不符:
    https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/591bb831-ea2d-4bc6-bc78-408eb0726715.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

注销登录

https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/8d7f606d-e1c5-4f82-a2e6-63588a42b12b.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

使用注销前的token尝试使用

https://wenzewoo-cdn.oss-cn-chengdu.aliyuncs.com/images/20200828/e3744a2e-930c-4d2b-9dbc-2cc25ea72311.png?x-oss-process=image/auto-orient,1/interlace,1/quality,q_70/format,jpg

评论