uni-app x 纯原生跨平台 APP Android 使用 Spring Security Authorization Server OAuth 2.1 公共客户端 PKCE 登录

相关文档

  1. Spring Security Authorization Server OAuth 2.1 公共客户端 PKCE
  2. uni-app x 纯原生跨平台 APP Android SHA256 算法
  3. uni-app x 纯原生跨平台 APP 自定义 tabBar 底部导航栏
  4. uni-app x 纯原生跨平台 APP 自定义 uni.request 预处理接口响应数据

源码

  1. 登录 · 极狐GitLab
  2. 登录 · 极狐GitLab

pages/me/me.uvue

  • 首次进入此页面后,如果没有登录,会跳转到登录页面
  • 后续进入此页面后,如果没有登录,不会有任何操作
  • 如果已经登录,会显示登录信息:用户名、Token 有效期
<template>
	<!-- #ifdef APP -->
	<scroll-view style="flex:1; background-color: #f5f5f5;">
	<!-- #endif -->
		<view v-if="token == ''" class="login-container">
			<button class="login-btn" @click="toLogin">去登录</button>
		</view>
		<view v-else class="me-container">

			<!-- AccessToken 卡片 -->
			<view class="card">

				<view class="card-content">
					<view class="info-item">
						<text class="info-label">用户ID:</text>
						<text class="info-value">{{ accessToken?.sub }}</text>
					</view>

					<view class="info-item">
						<text class="info-label">过期时间:</text>
						<text class="info-value">{{exp}}</text>
					</view>

					<view class="info-item">
						<text class="info-label">颁发时间:</text>
						<text class="info-value">{{iat}}</text>
					</view>

				</view>
			</view>

			<button class="logout-btn" @click="toLogout">退出登录</button>
		</view>
	<!-- #ifdef APP -->
	</scroll-view>
	<!-- #endif -->
</template>
<script setup lang="uts">
	import { onMounted, ref } from 'vue';
	import { TokenResponse, JwtAccessTokenPayload } from '@/uni_modules/xuxiaowei-common/js_sdk/token';
	import { formatTime } from '@/uni_modules/xuxiaowei-common/js_sdk/time';
	import request, { Options } from '@/uni_modules/xuxiaowei-common/js_sdk/request';

	const token = ref<string>('')
	const accessToken = ref<JwtAccessTokenPayload | null>(null)
	const exp = ref<string>('')
	const iat = ref<string>('')

	function toLogin() {
		uni.navigateTo({
			url: '/pages/login/login'
		});
	}

	function toLogout() {
		// 删除存储的JSON String
		uni.removeStorageSync('token')
		uni.removeStorageSync('access_token')
		uni.removeStorageSync('id_token')
		token.value = ''
		accessToken.value = null
		exp.value = ''
		iat.value = ''
	}

	// 检查token是否存在
	function checkToken() {
		// 从缓存中获取token JSON String
		const tokenInfoStr = uni.getStorageSync('token') as string | null;
		if (tokenInfoStr == null || tokenInfoStr == '') {
			// 如果token不存在,跳转到登录页面
			toLogin()
		} else {
			// 解析JSON String获取token信息
			const tokenInfo = JSON.parse<TokenResponse>(tokenInfoStr)
			token.value = tokenInfo?.access_token ?? ''

			const idTokenInfoStr = uni.getStorageSync('access_token') as string | null;
			if (idTokenInfoStr == null) {
				toLogout()
			} else {
				accessToken.value = JSON.parse<JwtAccessTokenPayload>(idTokenInfoStr)
				exp.value = formatTime(accessToken.value?.exp)
				iat.value = formatTime(accessToken.value?.iat)
			}
		}
	}

	// 页面加载时检查token
	onMounted(() => {
		checkToken();
	});
</script>
<style>
	.login-container {
		padding: 40rpx;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		min-height: 1334rpx;
		background-color: #f5f5f5;
	}

	.login-btn,
	.logout-btn {
		width: 100%;
		height: 80rpx;
		background-color: #007aff;
		color: #fff;
		font-size: 32rpx;
		border-radius: 8rpx;
		margin-top: 20rpx;
		border: none;
	}

	.logout-btn {
		background-color: #ff4757;
		margin-top: 30rpx;
	}

	.me-container {
		padding: 40rpx;
		display: flex;
		flex-direction: column;
		background-color: #f5f5f5;
	}

	.title {
		font-size: 36rpx;
		font-weight: bold;
		margin-bottom: 40rpx;
		color: #333;
		text-align: center;
	}

	/* 卡片样式 */
	.card {
		background-color: #fff;
		border-radius: 12rpx;
		padding: 30rpx;
		box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
		margin-bottom: 20rpx;
	}

	.card-title {
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-bottom: 24rpx;
		padding-bottom: 16rpx;
		border-bottom: 1rpx solid #eee;
	}

	.card-title-text {
		font-size: 32rpx;
		font-weight: bold;
		color: #333;
	}

	.card-content {
		display: flex;
		flex-direction: column;
	}

	/* 信息项样式 */
	.info-item {
		display: flex;
		flex-direction: row;
		align-items: center;
	}

	.info-item.full-width {
		margin-top: 10rpx;
	}

	.info-label {
		font-size: 26rpx;
		color: #666;
		/* `normal`|`bold`|`400`|`700` */
		font-weight: bold;
	}

	.info-value {
		font-size: 28rpx;
		color: #333;
		line-height: 1.5;
	}
</style>

pages/login/login.uvue

<template>
	<!-- #ifdef APP -->
	<scroll-view style="flex:1">
	<!-- #endif -->
		<view class="login-container">
			<view>
				<text class="login-title">登录</text>
			</view>
			<view class="form-item">
				<text class="label">用户名</text>
				<input v-model="username" type="text" placeholder="请输入用户名" class="input" />
			</view>
			<view class="form-item">
				<text class="label">密码</text>
				<input v-model="password" type="password" placeholder="请输入密码" class="input" />
			</view>
			<button @click="login" class="login-btn">登录</button>
		</view>
	<!-- #ifdef APP -->
	</scroll-view>
	<!-- #endif -->
</template>
<script setup lang="uts">
	import { ref } from 'vue';
	import { sha256Base64 } from '@/uni_modules/xuxiaowei-sha'
	import { LoginResponse, AuthorizedResponse, TokenResponse, JwtAccessTokenPayload, JwtIdTokenPayload, parseJwt } from '@/uni_modules/xuxiaowei-common/js_sdk/token';
	import { baseUrl } from '@/uni_modules/xuxiaowei-common/js_sdk/constants';
	import { randomString } from '@/uni_modules/xuxiaowei-common/js_sdk/random';
	import { extractCookies } from '@/uni_modules/xuxiaowei-common/js_sdk/cookie';
	import request from '@/uni_modules/xuxiaowei-common/js_sdk/request';

	// 定义响应式数据
	const username = ref<string>('xuxiaowei');
	const password = ref<string>('xuxiaowei');

	// 登录方法
	function login() {
		request<LoginResponse>({
			url: `${baseUrl()}/login`,
			method: 'POST',
			header: { 'Content-Type': 'application/x-www-form-urlencoded' },
			data: {
				username: username.value,
				password: password.value,
			},
			ok(res : RequestSuccess<LoginResponse>) {
				console.log('登录结果:', res)
				const data = res.data

				const redirectUri = data?.redirectUri
				const clientId = data?.clientId
				const scope = data?.scope
				const cookies = res.cookies
				const cookiesStr = extractCookies(cookies)
				console.log('cookiesStr:', cookiesStr)

				console.log('redirectUri:', redirectUri)
				console.log('clientId:', clientId)
				console.log('scope:', scope)

				const state = randomString(64)
				const codeVerifier = randomString(128)
				const codeChallenge = sha256Base64(codeVerifier)

				console.log('state:', state)
				console.log('codeVerifier:', codeVerifier)
				console.log('codeChallenge:', codeChallenge)

				request<AuthorizedResponse>({
					url: `${baseUrl()}/oauth2/authorize`,
					header: { 'cookie': cookiesStr },
					data: {
						response_type: 'code',
						client_id: clientId,
						redirect_uri: redirectUri,
						scope: scope,
						state: state,
						code_challenge: codeChallenge,
						code_challenge_method: 'S256',
					},
					ok(res : RequestSuccess<AuthorizedResponse>) {
						console.log('授权结果:', res)
						const data = res.data

						// 此处不能使用 !==
						if (state != data?.state) {
							console.log('授权异常,状态码 state 不匹配', state, data?.state)
							return
						}
						if (data?.code == '') {
							console.log('授权异常,授权码 code 为空')
							return
						}

						request<TokenResponse>({
							url: `${baseUrl()}/oauth2/token`,
							header: { 'Content-Type': 'application/x-www-form-urlencoded' },
							method: 'POST',
							data: {
								grant_type: 'authorization_code',
								code: data?.code,
								redirect_uri: redirectUri,
								client_id: clientId,
								code_verifier: codeVerifier,
							},
							ok(res : RequestSuccess<TokenResponse>) {
								console.log('获取 Token:', res)
								const data = res.data

								if (data?.access_token != '') {
									// 登录成功,存储token到缓存

									// 计算 expires_at:当前时间戳(秒)+ expires_in 秒
									const expiresIn = data?.expires_in ?? 0
									const now = Math.floor(Date.now() / 1000)
									const expiresAt = now + expiresIn

									// 解析 JWT token 信息
									const accessTokenPayload = parseJwt(data?.access_token as string, 'access_token')
									const idTokenPayload = parseJwt(data?.id_token as string, 'id_token')
									console.log('accessTokenPayload:', accessTokenPayload)
									console.log('idTokenPayload:', idTokenPayload)

									// 将 data 转为非空对象再添加属性
									const tokenData = data as TokenResponse
									tokenData.expires_at = expiresAt

									uni.setStorageSync('token', JSON.stringify(tokenData))
									uni.setStorageSync('access_token', JSON.stringify(accessTokenPayload))
									uni.setStorageSync('id_token', JSON.stringify(idTokenPayload))

									// 登录成功后跳转到我的页面
									uni.reLaunch({ url: '/pages/me/me' });
								} else {
									console.log('获取 Token 结果为空:', data)
									uni.showToast({ title: `获取 Token 结果为空`, icon: 'none' })
								}
							}
						})
					}
				})
			}
		})
	}
</script>
<style>
	.login-container {
		padding: 40rpx;
		display: flex;
		flex-direction: column;
		align-items: center;
		min-height: 1334rpx;
		background-color: #f5f5f5;
	}

	.login-title {
		font-size: 36rpx;
		font-weight: bold;
		margin-bottom: 60rpx;
		color: #333;
	}

	.form-item {
		width: 100%;
		margin-bottom: 40rpx;
	}

	.label {
		font-size: 28rpx;
		color: #666;
		margin-bottom: 12rpx;
	}

	.input {
		width: 100%;
		height: 80rpx;
		border: 1rpx solid #ddd;
		border-radius: 8rpx;
		padding: 0 20rpx;
		font-size: 28rpx;
		background-color: #fff;
		box-sizing: border-box;
	}

	.login-btn {
		width: 100%;
		height: 80rpx;
		background-color: #007aff;
		color: #fff;
		font-size: 32rpx;
		border-radius: 8rpx;
		margin-top: 20rpx;
	}
</style>

uni_modules/xuxiaowei-common/js_sdk/constants.uts

export function baseUrl() : string {
	const nodeEnv = process.env.NODE_ENV
	if (nodeEnv == 'development') {
		return 'http://172.25.25.24:51234'
	}
	// 此处应返回生产环境的地址,如:'https://uni-app-x.xuxiaowei.com.cn'
	return 'http://172.25.25.24:51234'
}

uni_modules/xuxiaowei-common/js_sdk/cookie.uts

// 解析 (Session) Cookie
export function extractCookies(cookies : string[]) : string {
	let str = ''
	for (let i = 0; i < cookies.length; i++) {
		const cookie = cookies[i]
		const parts = cookie.split(';')
		if (parts.length == 0) {
			continue
		}
		const part = parts[0].trim()
		if (part.length == 0) {
			continue
		}
		str += part + '; '
	}
	return str
}

uni_modules/xuxiaowei-common/js_sdk/random.uts

// 产生随机数
export function randomString(len : number) : string {
	let s = ''
	for (let i = 0; i < len; i++) {
		const r = Math.floor(Math.random() * 62)
		if (r < 10) {
			s += String.fromCharCode(48 + r)
		} else if (r < 36) {
			s += String.fromCharCode(65 + (r - 10))
		} else {
			s += String.fromCharCode(97 + (r - 36))
		}
	}
	return s
}

uni_modules/xuxiaowei-common/js_sdk/time.uts

// 格式化时间戳为可读时间
export function formatTime(timestamp : number | null) : string {
	if (timestamp == null) return '';
	const date = new Date(timestamp * 1000);
	const year = date.getFullYear();
	const month = (date.getMonth() + 1).toString().padStart(2, '0');
	const day = date.getDate().toString().padStart(2, '0');
	const hours = date.getHours().toString().padStart(2, '0');
	const minutes = date.getMinutes().toString().padStart(2, '0');
	const seconds = date.getSeconds().toString().padStart(2, '0');
	return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}

uni_modules/xuxiaowei-common/js_sdk/token.uts

export type LoginResponse = {
	timestamp : string | null
	status : number | null
	error : string | null
	path : string | null

	redirectUri : string | null
	clientId : string | null
	scope : string | null
}

export type AuthorizedResponse = {
	timestamp : string | null
	status : number | null
	error : string | null
	path : string | null

	code : string | null
	state : string | null
}

export type TokenResponse = {
	timestamp : string | null
	status : number | null
	error : string | null
	path : string | null

	access_token : string | null
	scope : string | null
	id_token : string | null
	token_type : string | null
	expires_in : number | null
	expires_at : number | null
}

export type JwtAccessTokenPayload = {
	sub : string
	aud : string
	nbf : number
	scope : string[]
	iss : string
	exp : number
	iat : number
	jti : string
}

export type JwtIdTokenPayload = {
	sub : string
	aud : string
	azp : string
	auth_time : number
	iss : string
	exp : number
	iat : number
	jti : string
	sid : string
}

export function decodeBase64Url(base64Url : string) : string {
	let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
	while (base64.length % 4 != 0) {
		base64 += '='
	}
	return base64
}

export function parseJwt(token : string, type : 'id_token' | 'access_token') : JwtIdTokenPayload | JwtAccessTokenPayload | null {
	try {
		const parts = token.split('.')
		if (parts.length != 3) {
			return null
		}
		const payload = parts[1]
		const decoded = decodeBase64Url(payload)
		const json = uni.base64ToArrayBuffer(decoded)
		const text = new TextDecoder().decode(json)
		if (type == 'id_token') {
			return JSON.parse<JwtIdTokenPayload>(text)
		} else if (type == 'access_token') {
			return JSON.parse<JwtAccessTokenPayload>(text)
		} else {
			console.error('type 类型不支持:', type)
			return null
		}
	} catch (e) {
		console.error('JWT 解析失败:', e)
		return null
	}
}

效果