From 54ddaf84387ad01eab193a54d60b8b4df300f593 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Fri, 3 Apr 2026 15:12:31 +0800 Subject: [PATCH] update jwt --- frontend/src/utils/__tests__/jwt.spec.ts | 65 ++++++++++++++++++++++++ frontend/src/utils/jwt.ts | 26 ++++++++++ 2 files changed, 91 insertions(+) create mode 100644 frontend/src/utils/__tests__/jwt.spec.ts create mode 100644 frontend/src/utils/jwt.ts diff --git a/frontend/src/utils/__tests__/jwt.spec.ts b/frontend/src/utils/__tests__/jwt.spec.ts new file mode 100644 index 0000000..438e1a6 --- /dev/null +++ b/frontend/src/utils/__tests__/jwt.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { decodeJwtPayload, extractRoleFromJwt } from '../jwt' + +/** 构造一个 fake JWT(header.payload.signature),payload 为给定对象 */ +function fakeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify(payload)) + return `${header}.${body}.fake-signature` +} + +describe('decodeJwtPayload', () => { + it('correctly decodes a standard JWT payload', () => { + const payload = { uid: 1, role: 'administrator', exp: 9999999999 } + const result = decodeJwtPayload(fakeJwt(payload)) + + expect(result).toEqual(payload) + }) + + it('returns null for non-three-segment string', () => { + expect(decodeJwtPayload('only-one-part')).toBeNull() + expect(decodeJwtPayload('two.parts')).toBeNull() + expect(decodeJwtPayload('')).toBeNull() + }) + + it('returns null for invalid base64 payload', () => { + expect(decodeJwtPayload('a.!!!invalid!!!.c')).toBeNull() + }) + + it('returns null for non-JSON payload', () => { + const nonJson = `a.${btoa('not json')}.c` + expect(decodeJwtPayload(nonJson)).toBeNull() + }) + + it('handles base64url encoding (- and _ characters)', () => { + // base64url uses - instead of + and _ instead of / + const payload = { data: 'test+value/here' } + const standard = btoa(JSON.stringify(payload)) + const urlSafe = standard.replace(/\+/g, '-').replace(/\//g, '_') + const token = `header.${urlSafe}.sig` + + expect(decodeJwtPayload(token)).toEqual(payload) + }) +}) + +describe('extractRoleFromJwt', () => { + it('extracts top-level role string', () => { + expect(extractRoleFromJwt(fakeJwt({ role: 'administrator' }))).toBe('administrator') + expect(extractRoleFromJwt(fakeJwt({ role: 'accessor' }))).toBe('accessor') + }) + + it('returns null when role field is missing', () => { + expect(extractRoleFromJwt(fakeJwt({ uid: 1 }))).toBeNull() + }) + + it('returns null when role is not a string', () => { + expect(extractRoleFromJwt(fakeJwt({ role: 123 }))).toBeNull() + expect(extractRoleFromJwt(fakeJwt({ role: null }))).toBeNull() + expect(extractRoleFromJwt(fakeJwt({ role: { name: 'admin' } }))).toBeNull() + }) + + it('returns null for invalid token', () => { + expect(extractRoleFromJwt('garbage')).toBeNull() + expect(extractRoleFromJwt('')).toBeNull() + }) +}) diff --git a/frontend/src/utils/jwt.ts b/frontend/src/utils/jwt.ts new file mode 100644 index 0000000..8ff3856 --- /dev/null +++ b/frontend/src/utils/jwt.ts @@ -0,0 +1,26 @@ +/** + * 解码 JWT payload(不验证签名,签名由服务端验证)。 + * 返回 payload 对象,解码失败返回 null。 + */ +export function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) + const decoded = atob(padded) + return JSON.parse(decoded) + } catch { + return null + } +} + +/** + * 从 JWT token 中提取用户角色。 + * 返回角色字符串(如 'administrator'),提取失败返回 null。 + */ +export function extractRoleFromJwt(token: string): string | null { + const payload = decodeJwtPayload(token) + if (!payload || typeof payload.role !== 'string') return null + return payload.role +}