相关文档
- Spring Security Authorization Server OAuth 2.1 公共客户端 PKCE
- uni-app x 纯原生跨平台 APP Android SHA256 算法
- uni-app x 纯原生跨平台 APP 自定义 tabBar 底部导航栏
- uni-app x 纯原生跨平台 APP 自定义 uni.request 预处理接口响应数据
<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>
<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>
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'
}
// 解析 (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
}
// 产生随机数
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
}
// 格式化时间戳为可读时间
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}`;
}
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
}
}