如何在Android中使用OpenID Connect实现登录页面认证
Android应用中OpenID Connect登录认证的具体实现步骤
我之前在Android项目里折腾过OpenID Connect的登录,太懂这种查了一堆资料还是摸不着头绪的感觉!下面给你一步步拆解实战过的实现步骤,跟着走基本能搞定:
一、准备工作:获取身份提供商(IDP)的配置信息
首先你得从你的OpenID Connect身份提供商(比如Auth0、Okta,或者自己搭建的Keycloak)拿到这些核心信息:
- Client ID:你的应用在IDP上注册的唯一标识
- 授权端点(Authorization Endpoint):用户跳转去登录授权的URL
- 令牌端点(Token Endpoint):用来交换获取访问令牌、ID令牌的URL
- 重定向URI(Redirect URI):授权完成后IDP跳转回你的应用的地址,格式一般是
scheme://host,比如com.yourapp.oidc://callback - 可选:注销端点(End Session Endpoint):用来实现登出的URL
二、添加依赖库
推荐使用AppAuth,这是Android官方推荐的用于OAuth 2.0和OpenID Connect的开源库,稳定且文档完善。在你的app/build.gradle(Module级别)里添加依赖:
dependencies { // AppAuth核心库 implementation 'net.openid:appauth:0.11.1' // 可选:用于验证JWT(ID Token)的库 implementation 'com.nimbusds:nimbus-jose-jwt:9.25.6' }
三、配置AndroidManifest.xml
- 添加网络权限(必须,因为要和IDP通信):
<uses-permission android:name="android.permission.INTERNET" />
- 配置处理回调的Activity(或者直接用AppAuth的默认回调Activity):
如果用自定义Activity,需要添加intent-filter来匹配重定向URI:
<activity android:name=".OidcCallbackActivity"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <!-- 这里的data要和你设置的重定向URI匹配 --> <data android:scheme="com.yourapp.oidc" android:host="callback" /> </intent-filter> </activity>
注意:如果你的IDP是第三方应用(比如Google登录),Android 11及以上需要在Manifest里添加包可见性配置,避免无法跳转:
<queries> <package android:name="com.google.android.gms" /> </queries>
四、构建并发起授权请求
在你的登录按钮点击事件里,构建授权请求并启动授权流程:
import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationService import net.openid.appauth.ResponseTypeValues import android.app.ActivityResultLauncher import android.content.Intent import android.widget.Toast // 1. 初始化AuthorizationService val authService = AuthorizationService(this) // 2. 构建授权请求 val authRequest = AuthorizationRequest.Builder( // IDP的授权端点(可以用Uri.parse转换为Uri对象) Uri.parse("https://your-idp-domain.com/auth"), // 你的Client ID "your-client-id", // 响应类型,OpenID Connect必须包含openid ResponseTypeValues.CODE, // 重定向URI Uri.parse("com.yourapp.oidc://callback") ) // 添加需要的权限范围,比如获取用户信息、邮箱等 .setScopes("openid", "profile", "email") .build() // 3. 启动授权页面(用AndroidX的registerForActivityResult更推荐) val authLauncher: ActivityResultLauncher<Intent> = registerForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { result.data?.let { intent -> // 处理授权响应,见下一步 handleAuthorizationResponse(intent, authService) } } else { // 用户取消了授权,处理错误逻辑 Toast.makeText(this, "授权取消", Toast.LENGTH_SHORT).show() } } // 启动授权Intent val authIntent = authService.getAuthorizationRequestIntent(authRequest) authLauncher.launch(authIntent)
五、处理授权响应并获取令牌
授权完成后,在回调里处理响应,交换获取访问令牌(Access Token)和ID令牌(ID Token):
private fun handleAuthorizationResponse(intent: Intent, authService: AuthorizationService) { val authResponse = net.openid.appauth.AuthorizationResponse.fromIntent(intent) val authException = net.openid.appauth.AuthorizationException.fromIntent(intent) if (authResponse != null && authException == null) { // 构建令牌请求 val tokenRequest = authResponse.createTokenExchangeRequest() // 发起令牌请求 authService.performTokenRequest(tokenRequest) { tokenResponse, tokenException -> if (tokenResponse != null && tokenException == null) { // 成功拿到令牌! val accessToken = tokenResponse.accessToken val idToken = tokenResponse.idToken val refreshToken = tokenResponse.refreshToken // 这里必须验证ID Token的合法性!不能直接信任 validateIdToken(idToken) // 持久化令牌(推荐用Android Keystore加密存储,或者加密的SharedPreferences) saveTokens(accessToken, idToken, refreshToken) // 跳转到主页面,完成登录 startActivity(Intent(this, MainActivity::class.java)) finish() } else { // 令牌请求失败,处理错误 Toast.makeText(this, "登录失败:${tokenException?.message}", Toast.LENGTH_SHORT).show() } } } else { // 授权失败,处理错误 Toast.makeText(this, "授权失败:${authException?.message}", Toast.LENGTH_SHORT).show() } }
六、验证ID Token的合法性(关键!)
ID Token是JWT格式,必须验证它的签名、有效期、受众(Audience)等,防止伪造。用nimbus-jose-jwt库的示例:
import com.nimbusds.jwt.JWT import com.nimbusds.jwt.JWTParser import com.nimbusds.jwt.SignedJWT import com.nimbusds.jose.JWSVerifier import com.nimbusds.jose.crypto.RSASSAVerifier import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.source.JWKSource import com.nimbusds.jose.proc.SecurityContext import java.util.Date private fun validateIdToken(idToken: String?) { idToken ?: throw IllegalArgumentException("ID Token不能为空") try { val jwt = JWTParser.parse(idToken) as SignedJWT // 从IDP的JWKS端点获取公钥(需要提前配置或动态获取) val jwkSource: JWKSource<SecurityContext> = // 这里实现从IDP获取公钥的逻辑 val verifier: JWSVerifier = RSASSAVerifier(jwkSource.getJWK(jwt.header.keyID, null)) // 验证签名 if (!jwt.verify(verifier)) { throw SecurityException("ID Token签名验证失败") } // 验证有效期 val claims = jwt.jwtClaimsSet val now = Date().time / 1000 if (claims.expirationTime.time / 1000 < now) { throw SecurityException("ID Token已过期") } // 验证受众(必须包含你的Client ID) if (!claims.audience.contains("your-client-id")) { throw SecurityException("ID Token受众不匹配") } // 验证 issuer(必须和IDP的域名一致) if (claims.issuer != "https://your-idp-domain.com") { throw SecurityException("ID Token发行方不匹配") } // 验证通过,安全使用用户信息 val userId = claims.subject val userName = claims.getStringClaim("name") val userEmail = claims.getStringClaim("email") } catch (e: Exception) { throw SecurityException("ID Token验证失败:${e.message}") } }
七、令牌持久化与刷新
- 持久化:不要明文存储令牌,推荐用Android Keystore生成密钥来加密令牌,或者使用Jetpack Security库来加密SharedPreferences。
- 刷新令牌:当Access Token过期时,用Refresh Token去Token端点获取新的Access Token,避免用户重复登录:
import net.openid.appauth.TokenRequest import net.openid.appauth.GrantTypeValues private fun refreshToken(refreshToken: String, authService: AuthorizationService) { val tokenRequest = TokenRequest.Builder( Uri.parse("https://your-idp-domain.com/token"), "your-client-id" ) .setGrantType(GrantTypeValues.REFRESH_TOKEN) .setRefreshToken(refreshToken) .build() authService.performTokenRequest(tokenRequest) { tokenResponse, tokenException -> if (tokenResponse != null) { // 更新存储的Access Token和Refresh Token(如果返回了新的Refresh Token) saveTokens(tokenResponse.accessToken, tokenResponse.idToken, tokenResponse.refreshToken ?: refreshToken) } } }
八、注销登录
- 本地注销:清除存储的所有令牌,跳转到登录页面。
- IDP注销:如果需要单点注销,跳转到IDP的注销端点,带上ID Token和重定向URI:
fun logout() { val idToken = getSavedIdToken() val logoutUri = Uri.parse("https://your-idp-domain.com/logout") .buildUpon() .appendQueryParameter("id_token_hint", idToken) .appendQueryParameter("post_logout_redirect_uri", "com.yourapp.oidc://logout-callback") .build() // 用浏览器打开注销页面 val intent = Intent(Intent.ACTION_VIEW, logoutUri) startActivity(intent) // 清除本地令牌 clearSavedTokens() startActivity(Intent(this, LoginActivity::class.java)) finish() }
内容的提问来源于stack exchange,提问作者dam
相关产品推荐
相关产品推荐

