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登录为例,流程是这样的:
- 用户点击「使用GitHub登录」
- 跳转到GitHub的授权页面
- 用户同意授权
- GitHub回调你的应用,带上授权码
- 你用授权码去GitHub换access_token
- 你用access_token去GitHub获取用户信息
- 你根据用户信息创建/登录本地用户,然后签发自己的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 认证流程
避坑指南
我曾经踩过的坑,你一定要注意:
- JWT密钥泄露:千万不要把私钥硬编码在代码里。我见过有人把私钥提交到GitHub,结果整个认证系统被攻破。建议用Vault或KMS管理密钥。
- 刷新令牌的存储:刷新令牌一定要存数据库或Redis,并且要支持撤销。我遇到过用户投诉「为什么我改了密码,旧设备还能登录?」——就是因为刷新令牌没撤销。
- 社交登录的回调URL:微信和GitHub的回调URL必须和注册时完全一致,包括协议、域名、端口。我曾经因为「http」和「https」不一致,调试了整整一个下午。
- JWT的过期时间:不要设太长,15-30分钟就够了。刷新令牌可以设长一点,但建议不超过7天。
总结
OAuth2 + JWT + 刷新令牌 + 社交登录,这套组合拳是现代Web应用的标配。你想想看,用户用微信扫个码就能登录,后端用JWT无状态认证,刷新令牌保证长期可用——用户体验和安全性都兼顾了。
嗯,最后说一句:安全没有银弹。OAuth2和JWT只是工具,真正决定安全性的,是你如何使用这些工具。多测试、多思考、多复盘,才能写出真正安全的系统。
如果你在集成过程中遇到问题,欢迎在公众号留言。我会定期整理常见问题,分享更多实战经验。