82、OAuth2认证:Spring Security OAuth2、JWT令牌、刷新令牌、社交登录

说到OAuth2认证,很多新手第一反应就是「一堆协议、一堆端点、一堆token」。嗯,我当年刚接触时也是这个感觉。但说白了,OAuth2就是一个「授权协议」,它解决的核心问题是:如何让第三方应用安全地访问你的资源,而不需要交出你的密码

举个例子。你写了一个App,想让用户用微信登录。你总不能让用户把微信密码给你吧?那太危险了。OAuth2就是干这个的——它让用户授权你的App,去微信那边拿一个「通行证」,然后你拿着这个通行证去访问用户的基本信息。

今天我们就来实战一下,用Spring Security OAuth2 + JWT + 刷新令牌 + 社交登录,搭建一个完整的认证授权系统。

核心知识点

  • OAuth2授权码模式(最常用)
  • JWT令牌的生成与验证
  • 刷新令牌(Refresh Token)机制
  • 社交登录(微信/GitHub)集成

OAuth2的四种模式,你该选哪个?

OAuth2定义了四种授权模式。我在项目中用得最多的是授权码模式,因为它最安全。其他三种模式各有适用场景,但如果你做的是Web应用或移动端,授权码模式是首选。

模式 适用场景 安全等级
授权码模式 Web应用、移动端
简化模式 纯前端应用(SPA)
密码模式 信任的客户端(如自家App)
客户端模式 服务间调用

我个人习惯是:对外暴露的API一律用授权码模式,内部服务间调用用客户端模式。密码模式?我建议你尽量别用,除非你确定客户端是你自己完全控制的。

JWT令牌:为什么大家都在用?

JWT(JSON Web Token)说白了就是一个「自包含」的令牌。它把用户信息、过期时间、签名都打包在一起。你拿到JWT,就能直接解析出用户是谁,不需要再去数据库查一遍。

我记得有一次,团队里有人问:「为什么不用普通的随机字符串当token?」嗯,随机字符串确实可以,但每次请求都要查数据库验证,性能差很多。JWT是「无状态」的,服务器不需要存session,直接验证签名就行。

来看一个JWT的结构:

// JWT由三部分组成:Header.Payload.Signature
// 示例:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjg4ODg4ODg4fQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

在Spring Security中集成JWT,我一般用nimbus-jose-jwt库,它是Spring Security官方推荐的。当然你也可以用jjwt,看个人习惯。

刷新令牌:为什么需要它?

JWT有个问题:一旦签发,在过期之前都是有效的。如果JWT被泄露了,攻击者可以一直用。所以JWT的过期时间通常设得很短,比如15分钟。

但用户不可能每15分钟登录一次吧?这就是刷新令牌(Refresh Token)的作用。它是个长期有效的令牌(比如7天),专门用来「续签」JWT。

我的经验:刷新令牌一定要存数据库,并且要支持「撤销」操作。我曾经遇到过一个问题:用户改了密码,但刷新令牌还在有效期,导致旧密码还能续签新JWT。后来我加了一个「令牌版本号」字段,每次改密码就递增版本号,旧令牌自动失效。

社交登录:微信、GitHub一键接入

社交登录其实就是OAuth2的一个具体应用。微信、GitHub、Google都是OAuth2的授权服务器。你只需要注册一个应用,拿到client_id和client_secret,然后按照OAuth2授权码模式走一遍流程就行。

以GitHub登录为例,流程是这样的:

  1. 用户点击「使用GitHub登录」
  2. 跳转到GitHub的授权页面
  3. 用户同意授权
  4. GitHub回调你的应用,带上授权码
  5. 你用授权码去GitHub换access_token
  6. 你用access_token去GitHub获取用户信息
  7. 你根据用户信息创建/登录本地用户,然后签发自己的JWT

你想想看,这个流程是不是和OAuth2授权码模式一模一样?没错,社交登录就是OAuth2的「实战版」。

代码实战:Spring Security OAuth2 + JWT

下面我给出一个精简版的实现。注意,这里只展示核心代码,完整的项目结构你可以参考我的GitHub仓库。

1. 依赖配置

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.nimbusds:nimbus-jose-jwt:9.31")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
}

2. JWT工具类

// JwtUtils.kt
@Component
class JwtUtils(
    @Value("\${jwt.secret}") private val secret: String,
    @Value("\${jwt.expiration}") private val expiration: Long
) {
    private val rsaKey: RSAKey by lazy {
        // 实际项目中应该从配置或密钥管理服务加载
        RSAKey.parse(secret)
    }

    fun generateToken(userId: String, roles: List<String>): String {
        val now = Instant.now()
        val claims = JWTClaimsSet.Builder()
            .subject(userId)
            .issueTime(Date.from(now))
            .expirationTime(Date.from(now.plusSeconds(expiration)))
            .claim("roles", roles)
            .build()

        val signedJWT = SignedJWT(
            JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.keyID).build(),
            claims
        )
        signedJWT.sign(RSASSASigner(rsaKey))
        return signedJWT.serialize()
    }

    fun validateToken(token: String): JWTClaimsSet? {
        return try {
            val signedJWT = SignedJWT.parse(token)
            val verifier = RSASSAVerifier(rsaKey)
            if (signedJWT.verify(verifier) && 
                signedJWT.jwtClaimsSet.expirationTime.after(Date())) {
                signedJWT.jwtClaimsSet
            } else null
        } catch (e: Exception) {
            null
        }
    }
}

3. 刷新令牌实现

// RefreshTokenService.kt
@Service
class RefreshTokenService(
    private val redisTemplate: StringRedisTemplate
) {
    fun createRefreshToken(userId: String): String {
        val token = UUID.randomUUID().toString()
        // 存储到Redis,7天过期
        redisTemplate.opsForValue().set(
            "refresh:$token",
            userId,
            Duration.ofDays(7)
        )
        return token
    }

    fun validateRefreshToken(token: String): String? {
        val userId = redisTemplate.opsForValue().get("refresh:$token")
        if (userId != null) {
            // 使用后立即删除(一次性使用)
            redisTemplate.delete("refresh:$token")
        }
        return userId
    }
}

4. 社交登录配置

// SecurityConfig.kt
@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val jwtUtils: JwtUtils,
    private val refreshTokenService: RefreshTokenService
) {
    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .oauth2Login { oauth2 ->
                oauth2
                    .authorizationEndpoint { auth ->
                        auth.baseUri("/oauth2/authorization")
                    }
                    .redirectionEndpoint { redirect ->
                        redirect.baseUri("/login/oauth2/code/*")
                    }
                    .userInfoEndpoint { userInfo ->
                        userInfo.userService(oAuth2UserService())
                    }
                    .successHandler(oAuth2SuccessHandler())
            }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/public/**").permitAll()
                    .requestMatchers("/api/auth/**").authenticated()
                    .anyRequest().authenticated()
            }
        return http.build()
    }

    private fun oAuth2UserService(): OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        return OAuth2UserService { userRequest ->
            val userInfoUri = userRequest.clientRegistration
                .providerDetails.userInfoEndpoint.uri
            // 调用第三方API获取用户信息
            // 返回自定义的OAuth2User
            OAuth2User(
                attributes = mapOf(
                    "id" to "12345",
                    "name" to "张三",
                    "email" to "zhangsan@example.com"
                ),
                authorities = listOf(SimpleGrantedAuthority("ROLE_USER"))
            )
        }
    }

    private fun oAuth2SuccessHandler(): AuthenticationSuccessHandler {
        return AuthenticationSuccessHandler { request, response, authentication ->
            val oAuth2User = authentication.principal as OAuth2User
            val userId = oAuth2User.attributes["id"] as String
            
            // 签发JWT
            val accessToken = jwtUtils.generateToken(userId, listOf("ROLE_USER"))
            val refreshToken = refreshTokenService.createRefreshToken(userId)
            
            // 返回给前端
            response.writer.write("""
                {
                    "access_token": "$accessToken",
                    "refresh_token": "$refreshToken",
                    "token_type": "Bearer",
                    "expires_in": 900
                }
            """.trimIndent())
        }
    }
}

核心流程图:OAuth2 + JWT 认证流程

OAuth2 + JWT 认证授权流程图 用户/客户端 前端应用 授权服务器 资源服务器 ① 请求登录 ② 授权码请求 ③ 用户授权 ④ 回调授权码 ⑤ 换取access_token ⑥ 返回JWT + Refresh Token ⑦ 存储令牌 ⑧ 携带JWT请求资源 ⑨ 验证JWT签名 ⑩ 返回资源 ⑪ 返回数据给前端 ⑫ 展示数据 JWT过期 → 使用Refresh Token续签 正常流程 刷新令牌流程

避坑指南

我曾经踩过的坑,你一定要注意:

  • JWT密钥泄露:千万不要把私钥硬编码在代码里。我见过有人把私钥提交到GitHub,结果整个认证系统被攻破。建议用Vault或KMS管理密钥。
  • 刷新令牌的存储:刷新令牌一定要存数据库或Redis,并且要支持撤销。我遇到过用户投诉「为什么我改了密码,旧设备还能登录?」——就是因为刷新令牌没撤销。
  • 社交登录的回调URL:微信和GitHub的回调URL必须和注册时完全一致,包括协议、域名、端口。我曾经因为「http」和「https」不一致,调试了整整一个下午。
  • JWT的过期时间:不要设太长,15-30分钟就够了。刷新令牌可以设长一点,但建议不超过7天。

总结

OAuth2 + JWT + 刷新令牌 + 社交登录,这套组合拳是现代Web应用的标配。你想想看,用户用微信扫个码就能登录,后端用JWT无状态认证,刷新令牌保证长期可用——用户体验和安全性都兼顾了。

嗯,最后说一句:安全没有银弹。OAuth2和JWT只是工具,真正决定安全性的,是你如何使用这些工具。多测试、多思考、多复盘,才能写出真正安全的系统。

如果你在集成过程中遇到问题,欢迎在公众号留言。我会定期整理常见问题,分享更多实战经验。

公众号:蓝海资料掘金营,微信deep3321