From b39157cfc69b68f413ead5db5a33d91ae527ad0f Mon Sep 17 00:00:00 2001 From: ronger Date: Sun, 10 May 2026 21:17:17 +0800 Subject: [PATCH 1/2] feat: add Rodak desktop browser auth --- .../apps/site/app/components/AppHeader.vue | 3 +- .../site/app/composables/useMemberSecurity.ts | 59 +++ .../site/app/pages/auth/desktop/authorize.vue | 132 ++++++ frontend/apps/site/app/pages/security.vue | 174 ++++++++ frontend/apps/site/app/types/security.ts | 9 + frontend/apps/site/package.json | 3 + frontend/packages/core-sdk/src/member.ts | 51 +++ frontend/packages/core-sdk/src/types.ts | 32 ++ frontend/pnpm-lock.yaml | 9 + .../standalone/app/components/AppHeader.vue | 5 +- .../app/composables/useMemberSecurity.ts | 59 +++ .../app/pages/auth/desktop/authorize.vue | 134 ++++++ .../standalone/app/pages/auth/login.vue | 3 - .../standalone/app/pages/security.vue | 151 +++++++ .../standalone/app/types/security.ts | 9 + frontend/templates/standalone/nuxt.config.ts | 3 +- frontend/templates/standalone/package.json | 3 + .../api/config/AppSecurityConfigurer.java | 5 +- .../controller/DesktopOAuthController.java | 85 ++++ .../controller/MemberSecurityController.java | 92 ++++ .../DesktopAuthorizationCodePayload.java | 19 + .../api/model/DesktopAuthorizeResponse.java | 9 + .../api/model/DesktopLogoutRequest.java | 13 + .../member/api/model/DesktopTokenRequest.java | 22 + .../api/model/DesktopTokenResponse.java | 31 ++ .../model/MemberClientSessionResponse.java | 29 ++ .../api/service/DesktopOAuthService.java | 43 ++ .../service/impl/DesktopOAuthServiceImpl.java | 403 ++++++++++++++++++ .../impl/DesktopOAuthServiceImplTest.java | 296 +++++++++++++ .../service/MemberClientSessionService.java | 61 +++ .../impl/MemberClientSessionServiceImpl.java | 122 ++++++ .../constant/DesktopOAuthConstants.java | 24 ++ .../member/constant/MemberJwtConstants.java | 6 + .../member/entity/MemberClientSession.java | 52 +++ .../mapper/MemberClientSessionMapper.java | 14 + ...40__Create_Member_Client_Session_Table.sql | 39 ++ 36 files changed, 2197 insertions(+), 7 deletions(-) create mode 100644 frontend/apps/site/app/composables/useMemberSecurity.ts create mode 100644 frontend/apps/site/app/pages/auth/desktop/authorize.vue create mode 100644 frontend/apps/site/app/pages/security.vue create mode 100644 frontend/apps/site/app/types/security.ts create mode 100644 frontend/templates/standalone/app/composables/useMemberSecurity.ts create mode 100644 frontend/templates/standalone/app/pages/auth/desktop/authorize.vue create mode 100644 frontend/templates/standalone/app/pages/security.vue create mode 100644 frontend/templates/standalone/app/types/security.ts create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/DesktopOAuthController.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizationCodePayload.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizeResponse.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopLogoutRequest.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenRequest.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenResponse.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/MemberClientSessionResponse.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/DesktopOAuthService.java create mode 100644 mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java create mode 100644 mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java create mode 100644 mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java create mode 100644 mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/impl/MemberClientSessionServiceImpl.java create mode 100644 mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/DesktopOAuthConstants.java create mode 100644 mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/entity/MemberClientSession.java create mode 100644 mortise-member/mortise-member-infra/src/main/java/com/rymcu/mortise/member/mapper/MemberClientSessionMapper.java create mode 100644 mortise-member/mortise-member-infra/src/main/resources/db/migration/V440__Create_Member_Client_Session_Table.sql diff --git a/frontend/apps/site/app/components/AppHeader.vue b/frontend/apps/site/app/components/AppHeader.vue index a5803878..20081bd2 100644 --- a/frontend/apps/site/app/components/AppHeader.vue +++ b/frontend/apps/site/app/components/AppHeader.vue @@ -39,7 +39,8 @@ const userMenuItems = computed(() => [ } ], [ - { label: '个人中心', icon: 'i-lucide-user', to: '/profile' } + { label: '个人中心', icon: 'i-lucide-user', to: '/profile' }, + { label: '账号安全', icon: 'i-lucide-shield-check', to: '/security' } ], [ { diff --git a/frontend/apps/site/app/composables/useMemberSecurity.ts b/frontend/apps/site/app/composables/useMemberSecurity.ts new file mode 100644 index 00000000..1ab9d2af --- /dev/null +++ b/frontend/apps/site/app/composables/useMemberSecurity.ts @@ -0,0 +1,59 @@ +import { + fetchMemberClientSessions, + revokeMemberClientSession +} from '@mortise/core-sdk' +import type { MemberClientSession } from '~/types/security' + +export function useMemberSecurity() { + const { $api } = useNuxtApp() + const sessions = useState('site-member-client-sessions', () => []) + const loading = useState('site-member-security-loading', () => false) + const error = useState('site-member-security-error', () => '') + + async function fetchSessions(): Promise { + loading.value = true + error.value = '' + try { + sessions.value = await fetchMemberClientSessions($api) + return sessions.value + } + catch (e) { + error.value = e instanceof Error ? e.message : '获取设备会话失败' + return [] + } + finally { + loading.value = false + } + } + + async function revokeSession(sessionId: string): Promise { + loading.value = true + error.value = '' + try { + const ok = await revokeMemberClientSession($api, sessionId) + if (ok) { + sessions.value = sessions.value.map(session => + session.id === sessionId + ? { ...session, status: 'revoked', revokedAt: new Date().toISOString() } + : session + ) + } + return ok + } + catch (e) { + error.value = e instanceof Error ? e.message : '撤销设备会话失败' + return false + } + finally { + loading.value = false + } + } + + return { + sessions: readonly(sessions), + loading: readonly(loading), + error: readonly(error), + fetchSessions, + revokeSession + } +} diff --git a/frontend/apps/site/app/pages/auth/desktop/authorize.vue b/frontend/apps/site/app/pages/auth/desktop/authorize.vue new file mode 100644 index 00000000..fd3a4d86 --- /dev/null +++ b/frontend/apps/site/app/pages/auth/desktop/authorize.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/apps/site/app/pages/security.vue b/frontend/apps/site/app/pages/security.vue new file mode 100644 index 00000000..af751455 --- /dev/null +++ b/frontend/apps/site/app/pages/security.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/apps/site/app/types/security.ts b/frontend/apps/site/app/types/security.ts new file mode 100644 index 00000000..61572fc1 --- /dev/null +++ b/frontend/apps/site/app/types/security.ts @@ -0,0 +1,9 @@ +import type { MemberClientSession } from '@mortise/core-sdk' + +export type { MemberClientSession } + +export interface SecurityDeviceSummary { + label: string + value: string + icon: string +} diff --git a/frontend/apps/site/package.json b/frontend/apps/site/package.json index 4e0a5f1f..3bdd1fd1 100644 --- a/frontend/apps/site/package.json +++ b/frontend/apps/site/package.json @@ -18,8 +18,11 @@ "@mortise/community-layer": "workspace:*", "@mortise/core-sdk": "workspace:*", "@nuxt/content": "^3.12.0", + "@nuxt/fonts": "^0.14.0", + "@nuxt/icon": "^2.2.1", "@nuxt/image": "^2.0.0", "@nuxt/ui": "^4.5.0", + "@nuxtjs/color-mode": "^3.5.2", "@pinia/nuxt": "^0.11.2", "@vueuse/core": "^14.2.1", "better-sqlite3": "^12.6.2", diff --git a/frontend/packages/core-sdk/src/member.ts b/frontend/packages/core-sdk/src/member.ts index eadaa8c6..fada85fe 100644 --- a/frontend/packages/core-sdk/src/member.ts +++ b/frontend/packages/core-sdk/src/member.ts @@ -1,6 +1,8 @@ import type { ApiInvoker } from './admin' import type { GlobalResult, + DesktopAuthorizeResponse, + MemberClientSession, OAuth2AuthUrlResponse, OAuth2LoginResponse, OAuth2QRCodeResponse, @@ -313,6 +315,55 @@ export async function fetchWechatSilentOpenId( return normalizeWeChatSilentOpenIdResponse(assertSuccess(response, '获取微信 openId 失败')) } +export async function authorizeDesktopClient( + api: ApiInvoker, + query: Record, +): Promise { + const response = await api>( + '/api/v1/app/desktop/oauth/authorize', + { + method: 'GET', + query, + }, + ) + + return assertSuccess(response, '授权 Rodak 失败') ?? null +} + +function normalizeMemberClientSession(value: MemberClientSession): MemberClientSession { + return { + ...value, + id: String(value.id), + } +} + +export async function fetchMemberClientSessions( + api: ApiInvoker, +): Promise { + const response = await api>( + '/api/v1/app/security/client-sessions', + { + method: 'GET', + }, + ) + + return (assertSuccess(response, '获取设备会话失败') ?? []).map(normalizeMemberClientSession) +} + +export async function revokeMemberClientSession( + api: ApiInvoker, + sessionId: string, +): Promise { + const response = await api>( + `/api/v1/app/security/client-sessions/${encodeURIComponent(sessionId)}`, + { + method: 'DELETE', + }, + ) + + return Boolean(assertSuccess(response, '撤销设备会话失败')) +} + export async function uploadAppFile>( api: ApiInvoker, formData: FormData, diff --git a/frontend/packages/core-sdk/src/types.ts b/frontend/packages/core-sdk/src/types.ts index 0efc0418..a43b1502 100644 --- a/frontend/packages/core-sdk/src/types.ts +++ b/frontend/packages/core-sdk/src/types.ts @@ -78,6 +78,38 @@ export interface TokenRefreshResponse { refreshExpiresIn?: number } +export interface DesktopAuthorizeResponse { + redirectUri?: string +} + +export interface DesktopTokenResponse { + memberId?: string | number | null + username?: string | null + nickname?: string | null + avatarUrl?: string | null + token?: string + refreshToken?: string + tokenType?: string + accessTokenExpiryMs?: number + refreshTokenExpiryMs?: number + sessionId?: string | number | null + clientId?: string | null +} + +export type MemberClientSessionStatus = 'active' | 'revoked' | string + +export interface MemberClientSession { + id: string + clientId?: string | null + clientName?: string | null + deviceName?: string | null + status: MemberClientSessionStatus + lastActiveAt?: string | null + revokedAt?: string | null + createdTime?: string | null + current?: boolean | null +} + export interface WeChatSilentOpenIdResponse { openId?: string isBound?: boolean diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 38820606..7027c4fa 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -177,12 +177,21 @@ importers: '@nuxt/content': specifier: ^3.12.0 version: 3.12.0(better-sqlite3@12.8.0)(magicast@0.5.2)(valibot@1.3.1(typescript@5.9.3)) + '@nuxt/fonts': + specifier: ^0.14.0 + version: 0.14.0(db0@0.3.4(better-sqlite3@12.8.0))(ioredis@5.10.0)(magicast@0.5.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3)) + '@nuxt/icon': + specifier: ^2.2.1 + version: 2.2.1(magicast@0.5.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue@3.5.30(typescript@5.9.3)) '@nuxt/image': specifier: ^2.0.0 version: 2.0.0(db0@0.3.4(better-sqlite3@12.8.0))(ioredis@5.10.0)(magicast@0.5.2) '@nuxt/ui': specifier: ^4.5.0 version: 4.5.1(@nuxt/content@3.12.0(better-sqlite3@12.8.0)(magicast@0.5.2)(valibot@1.3.1(typescript@5.9.3)))(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4(better-sqlite3@12.8.0))(embla-carousel@8.6.0)(ioredis@5.10.0)(magicast@0.5.2)(tailwindcss@4.2.1)(typescript@5.9.3)(valibot@1.3.1(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76) + '@nuxtjs/color-mode': + specifier: ^3.5.2 + version: 3.5.2(magicast@0.5.2) '@pinia/nuxt': specifier: ^0.11.2 version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))) diff --git a/frontend/templates/standalone/app/components/AppHeader.vue b/frontend/templates/standalone/app/components/AppHeader.vue index 63df5092..ac08b60d 100644 --- a/frontend/templates/standalone/app/components/AppHeader.vue +++ b/frontend/templates/standalone/app/components/AppHeader.vue @@ -28,7 +28,10 @@ const userMenuItems = computed(() => [ avatar: { src: avatarSrc.value, alt: displayName.value } } ], - [{ label: '个人中心', icon: 'i-lucide-user', to: '/profile' }], + [ + { label: '个人中心', icon: 'i-lucide-user', to: '/profile' }, + { label: '账号安全', icon: 'i-lucide-shield-check', to: '/security' } + ], [ { label: '退出登录', diff --git a/frontend/templates/standalone/app/composables/useMemberSecurity.ts b/frontend/templates/standalone/app/composables/useMemberSecurity.ts new file mode 100644 index 00000000..14b8c2a2 --- /dev/null +++ b/frontend/templates/standalone/app/composables/useMemberSecurity.ts @@ -0,0 +1,59 @@ +import { + fetchMemberClientSessions, + revokeMemberClientSession +} from '@mortise/core-sdk' +import type { MemberClientSession } from '~/types/security' + +export function useMemberSecurity() { + const { $api } = useNuxtApp() + const sessions = useState('standalone-member-client-sessions', () => []) + const loading = useState('standalone-member-security-loading', () => false) + const error = useState('standalone-member-security-error', () => '') + + async function fetchSessions(): Promise { + loading.value = true + error.value = '' + try { + sessions.value = await fetchMemberClientSessions($api) + return sessions.value + } + catch (e) { + error.value = e instanceof Error ? e.message : '获取设备会话失败' + return [] + } + finally { + loading.value = false + } + } + + async function revokeSession(sessionId: string): Promise { + loading.value = true + error.value = '' + try { + const ok = await revokeMemberClientSession($api, sessionId) + if (ok) { + sessions.value = sessions.value.map(session => + session.id === sessionId + ? { ...session, status: 'revoked', revokedAt: new Date().toISOString() } + : session + ) + } + return ok + } + catch (e) { + error.value = e instanceof Error ? e.message : '撤销设备会话失败' + return false + } + finally { + loading.value = false + } + } + + return { + sessions: readonly(sessions), + loading: readonly(loading), + error: readonly(error), + fetchSessions, + revokeSession + } +} diff --git a/frontend/templates/standalone/app/pages/auth/desktop/authorize.vue b/frontend/templates/standalone/app/pages/auth/desktop/authorize.vue new file mode 100644 index 00000000..4fd12914 --- /dev/null +++ b/frontend/templates/standalone/app/pages/auth/desktop/authorize.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/templates/standalone/app/pages/auth/login.vue b/frontend/templates/standalone/app/pages/auth/login.vue index 0bdaac1b..1e829d88 100644 --- a/frontend/templates/standalone/app/pages/auth/login.vue +++ b/frontend/templates/standalone/app/pages/auth/login.vue @@ -59,9 +59,6 @@ async function onSubmit(event: FormSubmitEvent) { errorMessage.value = '' try { await auth.login(event.data.account, event.data.password) - if (route.query.returnToChat === '1') { - useChatWidget().open() - } const redirect = typeof route.query.redirect === 'string' && route.query.redirect ? route.query.redirect : '/' diff --git a/frontend/templates/standalone/app/pages/security.vue b/frontend/templates/standalone/app/pages/security.vue new file mode 100644 index 00000000..86d1884b --- /dev/null +++ b/frontend/templates/standalone/app/pages/security.vue @@ -0,0 +1,151 @@ + + + diff --git a/frontend/templates/standalone/app/types/security.ts b/frontend/templates/standalone/app/types/security.ts new file mode 100644 index 00000000..61572fc1 --- /dev/null +++ b/frontend/templates/standalone/app/types/security.ts @@ -0,0 +1,9 @@ +import type { MemberClientSession } from '@mortise/core-sdk' + +export type { MemberClientSession } + +export interface SecurityDeviceSummary { + label: string + value: string + icon: string +} diff --git a/frontend/templates/standalone/nuxt.config.ts b/frontend/templates/standalone/nuxt.config.ts index e6c3c24c..fab0a36b 100644 --- a/frontend/templates/standalone/nuxt.config.ts +++ b/frontend/templates/standalone/nuxt.config.ts @@ -60,7 +60,8 @@ export default defineNuxtConfig({ refreshPath: '/api/v1/app/auth/refresh-token', callbackPath: '/api/v1/app/oauth2/callback', mePath: '/api/v1/app/auth/profile', - oauthAuthUrlPath: '/api/v1/app/oauth2/auth-url' + oauthAuthUrlPath: '/api/v1/app/oauth2/auth-url', + desktopAuthorizePath: '/auth/desktop/authorize' } } }, diff --git a/frontend/templates/standalone/package.json b/frontend/templates/standalone/package.json index 37f2a323..11684b4c 100644 --- a/frontend/templates/standalone/package.json +++ b/frontend/templates/standalone/package.json @@ -17,8 +17,11 @@ "@iconify-json/lucide": "^1.2.95", "@iconify-json/simple-icons": "^1.2.72", "@nuxt/content": "^3.12.0", + "@nuxt/fonts": "^0.14.0", + "@nuxt/icon": "^2.2.1", "@nuxt/image": "^2.0.0", "@nuxt/ui": "^4.5.0", + "@nuxtjs/color-mode": "^3.5.2", "@pinia/nuxt": "^0.11.2", "better-sqlite3": "^12.6.2", "nuxt": "^4.3.1", diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/config/AppSecurityConfigurer.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/config/AppSecurityConfigurer.java index b5ecc8a5..4d0965b0 100644 --- a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/config/AppSecurityConfigurer.java +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/config/AppSecurityConfigurer.java @@ -13,6 +13,7 @@ *

* 路径规则: * - /api/v1/app/auth/** : 公开接口 (注册、登录、发送验证码等) + * - /api/v1/app/desktop/oauth/token : 桌面端授权码换 token 公开接口 * - /api/v1/app/** : 需要会员认证 (ROLE_MEMBER) * * @author ronger @@ -29,7 +30,9 @@ public class AppSecurityConfigurer implements SecurityConfigurer { "/api/v1/app/auth/refresh-token-by-jwt", "/api/v1/app/auth/send-code", "/api/v1/app/auth/reset-password", - "/api/v1/app/auth/verify-code" + "/api/v1/app/auth/verify-code", + "/api/v1/app/desktop/oauth/token", + "/api/v1/app/desktop/oauth/logout" }; /** diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/DesktopOAuthController.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/DesktopOAuthController.java new file mode 100644 index 00000000..7d54da47 --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/DesktopOAuthController.java @@ -0,0 +1,85 @@ +package com.rymcu.mortise.member.api.controller; + +import com.rymcu.mortise.core.model.CurrentUser; +import com.rymcu.mortise.core.result.GlobalResult; +import com.rymcu.mortise.log.annotation.ApiLog; +import com.rymcu.mortise.member.api.model.DesktopAuthorizeResponse; +import com.rymcu.mortise.member.api.model.DesktopLogoutRequest; +import com.rymcu.mortise.member.api.model.DesktopTokenRequest; +import com.rymcu.mortise.member.api.model.DesktopTokenResponse; +import com.rymcu.mortise.member.api.service.DesktopOAuthService; +import com.rymcu.mortise.web.annotation.ApiController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 桌面端授权桥 Controller。 + * + * @author ronger + */ +@ApiController +@RequestMapping("/app/desktop/oauth") +@RequiredArgsConstructor +@Tag(name = "桌面端授权", description = "Rodak 桌面端浏览器授权登录接口") +public class DesktopOAuthController { + + private final DesktopOAuthService desktopOAuthService; + + @GetMapping("/authorize") + @Operation(summary = "创建桌面端授权码") + @ApiLog(recordParams = false, recordResponseBody = false, value = "创建桌面端授权码") + public GlobalResult authorize( + @AuthenticationPrincipal CurrentUser currentUser, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("state") String state, + @RequestParam("code_challenge") String codeChallenge, + @RequestParam("code_challenge_method") String codeChallengeMethod, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "device_name", required = false) String deviceName, + @RequestParam(value = "device_fingerprint", required = false) String deviceFingerprint) { + String callback = desktopOAuthService.authorize( + currentUser, + clientId, + redirectUri, + state, + codeChallenge, + codeChallengeMethod, + scope, + deviceName, + deviceFingerprint + ); + return GlobalResult.success(new DesktopAuthorizeResponse(callback)); + } + + @PostMapping("/token") + @Operation(summary = "桌面端授权码换取 Token") + @ApiLog(recordParams = false, recordRequestBody = false, recordResponseBody = false, value = "桌面端换取Token") + public GlobalResult token(@Valid @RequestBody DesktopTokenRequest request) { + return GlobalResult.success(desktopOAuthService.token(request)); + } + + @PostMapping("/logout") + @Operation(summary = "撤销桌面端会话") + @ApiLog(recordParams = false, recordRequestBody = false, recordResponseBody = false, value = "桌面端退出登录") + public GlobalResult logout(@AuthenticationPrincipal CurrentUser currentUser, + @RequestBody(required = false) DesktopLogoutRequest request, + @RequestParam(value = "refreshToken", required = false) String refreshToken, + @RequestParam(value = "sessionId", required = false) Long sessionId) { + String resolvedRefreshToken = request != null && request.refreshToken() != null + ? request.refreshToken() + : refreshToken; + Long resolvedSessionId = request != null && request.sessionId() != null + ? request.sessionId() + : sessionId; + return GlobalResult.success(desktopOAuthService.logout(currentUser, resolvedRefreshToken, resolvedSessionId)); + } +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java new file mode 100644 index 00000000..06af3eec --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java @@ -0,0 +1,92 @@ +package com.rymcu.mortise.member.api.controller; + +import com.rymcu.mortise.common.exception.BusinessException; +import com.rymcu.mortise.core.model.CurrentUser; +import com.rymcu.mortise.core.result.GlobalResult; +import com.rymcu.mortise.log.annotation.ApiLog; +import com.rymcu.mortise.member.api.model.MemberClientSessionResponse; +import com.rymcu.mortise.member.api.service.DesktopOAuthService; +import com.rymcu.mortise.member.constant.DesktopOAuthConstants; +import com.rymcu.mortise.member.entity.MemberClientSession; +import com.rymcu.mortise.member.service.MemberClientSessionService; +import com.rymcu.mortise.web.annotation.ApiController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; + +/** + * 会员账号安全中心 Controller。 + * + * @author ronger + */ +@ApiController +@RequestMapping("/app/security") +@RequiredArgsConstructor +@Tag(name = "会员账号安全", description = "会员设备与会话安全管理接口") +public class MemberSecurityController { + + private final MemberClientSessionService clientSessionService; + private final DesktopOAuthService desktopOAuthService; + + @GetMapping("/client-sessions") + @Operation(summary = "查询桌面客户端会话") + @ApiLog(recordParams = false, recordResponseBody = false, value = "查询会员客户端会话") + public GlobalResult> listClientSessions( + @AuthenticationPrincipal CurrentUser currentUser) { + Long memberId = requireMemberId(currentUser); + List sessions = clientSessionService.listMemberSessions(memberId) + .stream() + .map(this::toResponse) + .toList(); + return GlobalResult.success(sessions); + } + + @DeleteMapping("/client-sessions/{sessionId}") + @Operation(summary = "撤销桌面客户端会话") + @ApiLog(recordParams = false, recordResponseBody = false, value = "撤销会员客户端会话") + public GlobalResult revokeClientSession(@AuthenticationPrincipal CurrentUser currentUser, + @PathVariable Long sessionId) { + Long memberId = requireMemberId(currentUser); + Boolean revoked = desktopOAuthService.revokeSession(memberId, sessionId); + if (!Boolean.TRUE.equals(revoked)) { + throw new BusinessException("session_not_found"); + } + return GlobalResult.success(true); + } + + private Long requireMemberId(CurrentUser currentUser) { + if (currentUser == null || !currentUser.isAuthenticated() || currentUser.getUserId() == null) { + throw new BusinessException("unauthorized"); + } + return currentUser.getUserId(); + } + + private MemberClientSessionResponse toResponse(MemberClientSession session) { + return new MemberClientSessionResponse( + session.getId(), + session.getClientId(), + resolveClientName(session.getClientId()), + StringUtils.defaultIfBlank(session.getDeviceName(), "未知设备"), + session.getStatus(), + session.getLastActiveAt(), + session.getRevokedAt(), + session.getCreatedTime(), + false + ); + } + + private String resolveClientName(String clientId) { + if (DesktopOAuthConstants.RODAK_CLIENT_ID.equals(clientId)) { + return "Rodak"; + } + return StringUtils.defaultIfBlank(clientId, "未知客户端"); + } +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizationCodePayload.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizationCodePayload.java new file mode 100644 index 00000000..287aa7af --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizationCodePayload.java @@ -0,0 +1,19 @@ +package com.rymcu.mortise.member.api.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 桌面端授权码缓存载荷。 + */ +public record DesktopAuthorizationCodePayload( + Long memberId, + String clientId, + String redirectUri, + String codeChallenge, + String scope, + String deviceName, + String deviceFingerprintHash, + LocalDateTime expiresAt +) implements Serializable { +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizeResponse.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizeResponse.java new file mode 100644 index 00000000..c487dcde --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopAuthorizeResponse.java @@ -0,0 +1,9 @@ +package com.rymcu.mortise.member.api.model; + +/** + * 桌面端授权响应。 + * + * @param redirectUri 回调地址 + */ +public record DesktopAuthorizeResponse(String redirectUri) { +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopLogoutRequest.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopLogoutRequest.java new file mode 100644 index 00000000..c00c3b52 --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopLogoutRequest.java @@ -0,0 +1,13 @@ +package com.rymcu.mortise.member.api.model; + +/** + * 桌面端退出请求。 + * + * @param refreshToken 刷新令牌 + * @param sessionId 客户端会话ID + */ +public record DesktopLogoutRequest( + String refreshToken, + Long sessionId +) { +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenRequest.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenRequest.java new file mode 100644 index 00000000..a3f83ec9 --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenRequest.java @@ -0,0 +1,22 @@ +package com.rymcu.mortise.member.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; + +/** + * 桌面端 Token 请求。 + * + * @param grantType 授权类型 + * @param code 授权码 + * @param redirectUri 回调地址 + * @param codeVerifier PKCE verifier + * @param refreshToken 刷新令牌 + */ +public record DesktopTokenRequest( + @NotBlank @JsonProperty("grant_type") String grantType, + String code, + @JsonProperty("redirect_uri") String redirectUri, + @JsonProperty("code_verifier") String codeVerifier, + @JsonProperty("refresh_token") String refreshToken +) { +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenResponse.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenResponse.java new file mode 100644 index 00000000..b0706e10 --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/DesktopTokenResponse.java @@ -0,0 +1,31 @@ +package com.rymcu.mortise.member.api.model; + +/** + * 桌面端 Token 响应。 + * + * @param memberId 会员ID + * @param username 用户名 + * @param nickname 昵称 + * @param avatarUrl 头像 + * @param token 访问令牌 + * @param refreshToken 刷新令牌 + * @param tokenType Token 类型 + * @param accessTokenExpiryMs 访问令牌有效期 + * @param refreshTokenExpiryMs 刷新令牌有效期 + * @param sessionId 客户端会话ID + * @param clientId 客户端ID + */ +public record DesktopTokenResponse( + Long memberId, + String username, + String nickname, + String avatarUrl, + String token, + String refreshToken, + String tokenType, + Long accessTokenExpiryMs, + Long refreshTokenExpiryMs, + Long sessionId, + String clientId +) { +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/MemberClientSessionResponse.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/MemberClientSessionResponse.java new file mode 100644 index 00000000..4a3dfe00 --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/model/MemberClientSessionResponse.java @@ -0,0 +1,29 @@ +package com.rymcu.mortise.member.api.model; + +import java.time.LocalDateTime; + +/** + * 会员客户端会话响应。 + * + * @param id 会话ID + * @param clientId 客户端ID + * @param clientName 客户端名称 + * @param deviceName 设备名称 + * @param status 状态 + * @param lastActiveAt 最近活跃时间 + * @param revokedAt 撤销时间 + * @param createdTime 创建时间 + * @param current 是否当前会话 + */ +public record MemberClientSessionResponse( + Long id, + String clientId, + String clientName, + String deviceName, + String status, + LocalDateTime lastActiveAt, + LocalDateTime revokedAt, + LocalDateTime createdTime, + Boolean current +) { +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/DesktopOAuthService.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/DesktopOAuthService.java new file mode 100644 index 00000000..f91e5718 --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/DesktopOAuthService.java @@ -0,0 +1,43 @@ +package com.rymcu.mortise.member.api.service; + +import com.rymcu.mortise.core.model.CurrentUser; +import com.rymcu.mortise.member.api.model.DesktopTokenRequest; +import com.rymcu.mortise.member.api.model.DesktopTokenResponse; + +/** + * 桌面端授权桥服务。 + * + * @author ronger + */ +public interface DesktopOAuthService { + + /** + * 创建授权码。 + * + * @return 回调地址 + */ + String authorize(CurrentUser currentUser, + String clientId, + String redirectUri, + String state, + String codeChallenge, + String codeChallengeMethod, + String scope, + String deviceName, + String deviceFingerprint); + + /** + * 换取或刷新 Token。 + */ + DesktopTokenResponse token(DesktopTokenRequest request); + + /** + * 退出桌面端会话。 + */ + Boolean logout(CurrentUser currentUser, String refreshToken, Long sessionId); + + /** + * 撤销指定桌面端会话及其刷新令牌。 + */ + Boolean revokeSession(Long memberId, Long sessionId); +} diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java new file mode 100644 index 00000000..88f6632d --- /dev/null +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java @@ -0,0 +1,403 @@ +package com.rymcu.mortise.member.api.service.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rymcu.mortise.auth.constant.AuthCacheConstant; +import com.rymcu.mortise.auth.service.AuthCacheService; +import com.rymcu.mortise.auth.util.JwtTokenUtil; +import com.rymcu.mortise.cache.service.CacheService; +import com.rymcu.mortise.common.exception.BusinessException; +import com.rymcu.mortise.common.util.Utils; +import com.rymcu.mortise.core.model.CurrentUser; +import com.rymcu.mortise.member.api.model.DesktopAuthorizationCodePayload; +import com.rymcu.mortise.member.api.model.DesktopTokenRequest; +import com.rymcu.mortise.member.api.model.DesktopTokenResponse; +import com.rymcu.mortise.member.api.service.ApiMemberService; +import com.rymcu.mortise.member.api.service.DesktopOAuthService; +import com.rymcu.mortise.member.constant.DesktopOAuthConstants; +import com.rymcu.mortise.member.constant.MemberJwtConstants; +import com.rymcu.mortise.member.entity.Member; +import com.rymcu.mortise.member.entity.MemberClientSession; +import com.rymcu.mortise.member.service.MemberClientSessionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 桌面端授权桥服务实现。 + * + * @author ronger + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DesktopOAuthServiceImpl implements DesktopOAuthService { + + private static final String ERROR_INVALID_CLIENT = "invalid_client"; + private static final String ERROR_INVALID_REDIRECT_URI = "invalid_redirect_uri"; + private static final String ERROR_INVALID_STATE = "invalid_state"; + private static final String ERROR_INVALID_CODE = "invalid_code"; + private static final String ERROR_PKCE_FAILED = "pkce_verification_failed"; + private static final String ERROR_SESSION_REVOKED = "session_revoked"; + private static final String ERROR_AUTHORIZATION_EXPIRED = "authorization_expired"; + + private final ApiMemberService memberService; + private final MemberClientSessionService clientSessionService; + private final AuthCacheService authCacheService; + private final CacheService cacheService; + private final JwtTokenUtil jwtTokenUtil; + private final ObjectMapper objectMapper; + + @Override + public String authorize(CurrentUser currentUser, + String clientId, + String redirectUri, + String state, + String codeChallenge, + String codeChallengeMethod, + String scope, + String deviceName, + String deviceFingerprint) { + if (currentUser == null || !currentUser.isAuthenticated()) { + throw new BusinessException("unauthorized"); + } + validateAuthorizeRequest(clientId, redirectUri, state, codeChallenge, codeChallengeMethod); + + String code = Utils.genKey(); + String normalizedScope = StringUtils.defaultIfBlank(scope, DesktopOAuthConstants.DEFAULT_SCOPE); + String deviceFingerprintHash = hashNullable(deviceFingerprint); + DesktopAuthorizationCodePayload payload = new DesktopAuthorizationCodePayload( + currentUser.getUserId(), + clientId, + redirectUri, + codeChallenge, + normalizedScope, + StringUtils.abbreviate(StringUtils.trimToNull(deviceName), 120), + deviceFingerprintHash, + LocalDateTime.now().plusMinutes(DesktopOAuthConstants.AUTHORIZATION_CODE_EXPIRE_MINUTES) + ); + + cacheService.set( + DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE, + hashToken(code), + serializeAuthorizationCodePayload(payload), + DesktopOAuthConstants.AUTHORIZATION_CODE_EXPIRE_MINUTES, + TimeUnit.MINUTES + ); + + log.info("创建桌面端授权码: memberId={}, clientId={}, redirectUri={}", + currentUser.getUserId(), clientId, redirectUri); + + return UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("code", code) + .queryParam("state", state) + .build(true) + .toUriString(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DesktopTokenResponse token(DesktopTokenRequest request) { + if (request == null || StringUtils.isBlank(request.grantType())) { + throw new BusinessException("unsupported_grant_type"); + } + + if (DesktopOAuthConstants.GRANT_TYPE_AUTHORIZATION_CODE.equals(request.grantType())) { + return exchangeAuthorizationCode(request); + } + + if (DesktopOAuthConstants.GRANT_TYPE_REFRESH_TOKEN.equals(request.grantType())) { + return refreshDesktopToken(request); + } + + throw new BusinessException("unsupported_grant_type"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean logout(CurrentUser currentUser, String refreshToken, Long sessionId) { + if (StringUtils.isNotBlank(refreshToken)) { + String refreshTokenHash = hashToken(refreshToken); + Long cachedSessionId = cacheService.get(DesktopOAuthConstants.REFRESH_SESSION_CACHE, refreshTokenHash, Long.class); + if (cachedSessionId != null) { + clientSessionService.revokeSession(cachedSessionId); + cacheService.delete(DesktopOAuthConstants.REFRESH_SESSION_CACHE, refreshTokenHash); + cacheService.delete(DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, cachedSessionId.toString()); + } + authCacheService.removeMemberRefreshToken(refreshTokenHash); + } + + if (sessionId != null && currentUser != null) { + MemberClientSession session = clientSessionService.getById(sessionId); + if (session != null + && session.getMemberId().equals(currentUser.getUserId())) { + Boolean revoked = clientSessionService.revokeSession(sessionId); + if (Boolean.TRUE.equals(revoked)) { + revokeRefreshTokenBySessionId(sessionId); + } + return revoked; + } + } + + return true; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean revokeSession(Long memberId, Long sessionId) { + if (memberId == null || sessionId == null) { + return false; + } + + Boolean revoked = clientSessionService.revokeMemberSession(memberId, sessionId); + if (!Boolean.TRUE.equals(revoked)) { + return false; + } + + revokeRefreshTokenBySessionId(sessionId); + return true; + } + + private DesktopTokenResponse exchangeAuthorizationCode(DesktopTokenRequest request) { + if (StringUtils.isBlank(request.code()) || StringUtils.isBlank(request.redirectUri()) + || StringUtils.isBlank(request.codeVerifier())) { + throw new BusinessException(ERROR_INVALID_CODE); + } + + String codeKey = hashToken(request.code()); + String payloadJson = cacheService.get( + DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE, + codeKey, + String.class + ); + cacheService.delete(DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE, codeKey); + + DesktopAuthorizationCodePayload payload = deserializeAuthorizationCodePayload(payloadJson); + if (payload == null) { + throw new BusinessException(ERROR_INVALID_CODE); + } + if (payload.expiresAt() == null || payload.expiresAt().isBefore(LocalDateTime.now())) { + throw new BusinessException(ERROR_AUTHORIZATION_EXPIRED); + } + if (!payload.redirectUri().equals(request.redirectUri())) { + throw new BusinessException(ERROR_INVALID_REDIRECT_URI); + } + if (!verifyPkce(request.codeVerifier(), payload.codeChallenge())) { + throw new BusinessException(ERROR_PKCE_FAILED); + } + + Member member = getActiveMember(payload.memberId()); + MemberClientSession session = clientSessionService.createSession( + member.getId(), + payload.clientId(), + payload.deviceName(), + payload.deviceFingerprintHash() + ); + + return buildTokenResponse(member, session, payload.scope(), null); + } + + private DesktopTokenResponse refreshDesktopToken(DesktopTokenRequest request) { + if (StringUtils.isBlank(request.refreshToken())) { + throw new BusinessException(ERROR_SESSION_REVOKED); + } + + String refreshTokenHash = hashToken(request.refreshToken()); + Long memberId = authCacheService.getMemberIdByRefreshToken(refreshTokenHash); + Long sessionId = cacheService.get(DesktopOAuthConstants.REFRESH_SESSION_CACHE, refreshTokenHash, Long.class); + if (memberId == null || sessionId == null) { + throw new BusinessException(ERROR_SESSION_REVOKED); + } + + MemberClientSession session = clientSessionService.touchActiveSession(sessionId); + if (session == null || !memberId.equals(session.getMemberId())) { + authCacheService.removeMemberRefreshToken(refreshTokenHash); + cacheService.delete(DesktopOAuthConstants.REFRESH_SESSION_CACHE, refreshTokenHash); + cacheService.delete(DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, sessionId.toString()); + throw new BusinessException(ERROR_SESSION_REVOKED); + } + + Member member = getActiveMember(memberId); + return buildTokenResponse(member, session, DesktopOAuthConstants.DEFAULT_SCOPE, request.refreshToken()); + } + + private DesktopTokenResponse buildTokenResponse(Member member, + MemberClientSession session, + String scope, + String oldRefreshToken) { + if (StringUtils.isNotBlank(oldRefreshToken)) { + authCacheService.removeMemberRefreshToken(hashToken(oldRefreshToken)); + cacheService.delete(DesktopOAuthConstants.REFRESH_SESSION_CACHE, hashToken(oldRefreshToken)); + cacheService.delete(DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, session.getId().toString()); + } + + Map claims = buildClaims(member, session, scope); + String jwtToken = jwtTokenUtil.generateToken(resolveSubject(member), claims); + String refreshToken = Utils.genKey(); + String refreshTokenHash = hashToken(refreshToken); + authCacheService.storeMemberRefreshToken(refreshTokenHash, member.getId()); + cacheService.set( + DesktopOAuthConstants.REFRESH_SESSION_CACHE, + refreshTokenHash, + session.getId(), + AuthCacheConstant.MEMBER_REFRESH_TOKEN_EXPIRE_HOURS, + TimeUnit.HOURS + ); + cacheService.set( + DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, + session.getId().toString(), + refreshTokenHash, + AuthCacheConstant.MEMBER_REFRESH_TOKEN_EXPIRE_HOURS, + TimeUnit.HOURS + ); + + return new DesktopTokenResponse( + member.getId(), + member.getUsername(), + member.getNickname(), + member.getAvatarUrl(), + jwtToken, + refreshToken, + jwtTokenUtil.getTokenPrefix().trim(), + MemberJwtConstants.ACCESS_TOKEN_EXPIRY_MS, + Duration.ofHours(AuthCacheConstant.MEMBER_REFRESH_TOKEN_EXPIRE_HOURS).toMillis(), + session.getId(), + session.getClientId() + ); + } + + private void revokeRefreshTokenBySessionId(Long sessionId) { + String refreshTokenHash = cacheService.get( + DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, + sessionId.toString(), + String.class + ); + if (StringUtils.isBlank(refreshTokenHash)) { + return; + } + + authCacheService.removeMemberRefreshToken(refreshTokenHash); + cacheService.delete(DesktopOAuthConstants.REFRESH_SESSION_CACHE, refreshTokenHash); + cacheService.delete(DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, sessionId.toString()); + } + + private Map buildClaims(Member member, MemberClientSession session, String scope) { + Map claims = new HashMap<>(); + claims.put(MemberJwtConstants.CLAIM_MEMBER_ID, member.getId()); + claims.put(MemberJwtConstants.CLAIM_TYPE, MemberJwtConstants.TYPE_MEMBER); + claims.put(MemberJwtConstants.CLAIM_LOGIN_TYPE, MemberJwtConstants.LOGIN_TYPE_DESKTOP); + claims.put(MemberJwtConstants.CLAIM_SESSION_ID, session.getId()); + claims.put(MemberJwtConstants.CLAIM_CLIENT_ID, session.getClientId()); + claims.put(MemberJwtConstants.CLAIM_LOGIN_CHANNEL, MemberJwtConstants.LOGIN_CHANNEL_DESKTOP_BROWSER); + claims.put(MemberJwtConstants.CLAIM_SCOPE, StringUtils.defaultIfBlank(scope, DesktopOAuthConstants.DEFAULT_SCOPE)); + return claims; + } + + private Member getActiveMember(Long memberId) { + Member member = memberService.getMemberById(memberId); + if (member == null) { + throw new BusinessException("member_not_found"); + } + member.setPasswordHash(null); + return member; + } + + private void validateAuthorizeRequest(String clientId, + String redirectUri, + String state, + String codeChallenge, + String codeChallengeMethod) { + if (!DesktopOAuthConstants.RODAK_CLIENT_ID.equals(clientId)) { + throw new BusinessException(ERROR_INVALID_CLIENT); + } + if (!DesktopOAuthConstants.CODE_CHALLENGE_METHOD_S256.equals(codeChallengeMethod)) { + throw new BusinessException(ERROR_PKCE_FAILED); + } + if (StringUtils.isBlank(state)) { + throw new BusinessException(ERROR_INVALID_STATE); + } + if (StringUtils.isBlank(codeChallenge)) { + throw new BusinessException(ERROR_PKCE_FAILED); + } + validateLoopbackRedirectUri(redirectUri); + } + + private void validateLoopbackRedirectUri(String redirectUri) { + try { + URI uri = URI.create(redirectUri); + if (!"http".equalsIgnoreCase(uri.getScheme()) + || !"127.0.0.1".equals(uri.getHost()) + || uri.getPort() <= 0 + || !"/auth/callback".equals(uri.getPath())) { + throw new BusinessException(ERROR_INVALID_REDIRECT_URI); + } + } catch (IllegalArgumentException e) { + throw new BusinessException(ERROR_INVALID_REDIRECT_URI, e); + } + } + + private boolean verifyPkce(String codeVerifier, String expectedChallenge) { + return expectedChallenge.equals(base64UrlSha256(codeVerifier)); + } + + private String serializeAuthorizationCodePayload(DesktopAuthorizationCodePayload payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new BusinessException("authorization_code_serialization_failed", e); + } + } + + private DesktopAuthorizationCodePayload deserializeAuthorizationCodePayload(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + return objectMapper.readValue(payloadJson, DesktopAuthorizationCodePayload.class); + } catch (JsonProcessingException e) { + throw new BusinessException(ERROR_INVALID_CODE, e); + } + } + + private String base64UrlSha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private String hashNullable(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + return hashToken(value); + } + + private String hashToken(String value) { + return base64UrlSha256(value); + } + + private String resolveSubject(Member member) { + if (StringUtils.isNotBlank(member.getUsername())) { + return member.getUsername(); + } + return StringUtils.defaultIfBlank(member.getEmail(), member.getPhone()); + } +} diff --git a/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java b/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java new file mode 100644 index 00000000..3144eea1 --- /dev/null +++ b/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java @@ -0,0 +1,296 @@ +package com.rymcu.mortise.member.api.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.rymcu.mortise.auth.constant.AuthCacheConstant; +import com.rymcu.mortise.auth.service.AuthCacheService; +import com.rymcu.mortise.auth.util.JwtTokenUtil; +import com.rymcu.mortise.cache.service.CacheService; +import com.rymcu.mortise.common.exception.BusinessException; +import com.rymcu.mortise.core.model.CurrentUser; +import com.rymcu.mortise.member.api.model.DesktopAuthorizationCodePayload; +import com.rymcu.mortise.member.api.model.DesktopTokenRequest; +import com.rymcu.mortise.member.api.model.DesktopTokenResponse; +import com.rymcu.mortise.member.api.service.ApiMemberService; +import com.rymcu.mortise.member.constant.DesktopOAuthConstants; +import com.rymcu.mortise.member.entity.Member; +import com.rymcu.mortise.member.entity.MemberClientSession; +import com.rymcu.mortise.member.service.MemberClientSessionService; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DesktopOAuthServiceImplTest { + + @Test + void authorizeShouldCreateOneTimeCodeAndReturnLoopbackRedirect() { + TestFixture fixture = new TestFixture(); + CurrentUser currentUser = currentUser(5L); + + String redirect = fixture.service.authorize( + currentUser, + DesktopOAuthConstants.RODAK_CLIENT_ID, + "http://127.0.0.1:49152/auth/callback", + "state-1", + "challenge", + DesktopOAuthConstants.CODE_CHALLENGE_METHOD_S256, + "profile", + "Dev PC", + "fingerprint" + ); + + assertEquals(true, redirect.startsWith("http://127.0.0.1:49152/auth/callback?code=")); + assertEquals(true, redirect.contains("&state=state-1")); + verify(fixture.cacheService).set( + eq(DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE), + any(String.class), + any(String.class), + eq(DesktopOAuthConstants.AUTHORIZATION_CODE_EXPIRE_MINUTES), + eq(TimeUnit.MINUTES) + ); + } + + @Test + void authorizeShouldRejectNonLoopbackRedirectUri() { + TestFixture fixture = new TestFixture(); + + BusinessException error = assertThrows(BusinessException.class, () -> fixture.service.authorize( + currentUser(5L), + DesktopOAuthConstants.RODAK_CLIENT_ID, + "https://example.com/auth/callback", + "state-1", + "challenge", + DesktopOAuthConstants.CODE_CHALLENGE_METHOD_S256, + "profile", + "Dev PC", + "fingerprint" + )); + + assertEquals("invalid_redirect_uri", error.getMessage()); + } + + @Test + void tokenShouldRejectWrongPkceVerifierAndDeleteCode() { + TestFixture fixture = new TestFixture(); + String code = "code-1"; + when(fixture.cacheService.get( + eq(DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE), + any(String.class), + eq(String.class) + )).thenReturn(payloadJson(new DesktopAuthorizationCodePayload( + 5L, + DesktopOAuthConstants.RODAK_CLIENT_ID, + "http://127.0.0.1:49152/auth/callback", + challengeOf("right-verifier"), + "profile", + "Dev PC", + "device-hash", + LocalDateTime.now().plusMinutes(5) + ))); + + BusinessException error = assertThrows(BusinessException.class, () -> fixture.service.token( + new DesktopTokenRequest( + DesktopOAuthConstants.GRANT_TYPE_AUTHORIZATION_CODE, + code, + "http://127.0.0.1:49152/auth/callback", + "wrong-verifier", + null + ) + )); + + assertEquals("pkce_verification_failed", error.getMessage()); + verify(fixture.cacheService).delete(eq(DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE), any(String.class)); + } + + @Test + void tokenShouldExchangeAuthorizationCodeAndCreateClientSession() { + TestFixture fixture = new TestFixture(); + Member member = member(); + MemberClientSession session = session(); + when(fixture.cacheService.get( + eq(DesktopOAuthConstants.AUTHORIZATION_CODE_CACHE), + any(String.class), + eq(String.class) + )).thenReturn(payloadJson(new DesktopAuthorizationCodePayload( + 5L, + DesktopOAuthConstants.RODAK_CLIENT_ID, + "http://127.0.0.1:49152/auth/callback", + challengeOf("verifier"), + "profile", + "Dev PC", + "device-hash", + LocalDateTime.now().plusMinutes(5) + ))); + when(fixture.memberService.getMemberById(5L)).thenReturn(member); + when(fixture.clientSessionService.createSession(5L, DesktopOAuthConstants.RODAK_CLIENT_ID, "Dev PC", "device-hash")) + .thenReturn(session); + when(fixture.jwtTokenUtil.generateToken(eq("ronger"), anyMap())).thenReturn("jwt-token"); + when(fixture.jwtTokenUtil.getTokenPrefix()).thenReturn("Bearer "); + + DesktopTokenResponse response = fixture.service.token(new DesktopTokenRequest( + DesktopOAuthConstants.GRANT_TYPE_AUTHORIZATION_CODE, + "code-1", + "http://127.0.0.1:49152/auth/callback", + "verifier", + null + )); + + assertNotNull(response.refreshToken()); + assertEquals("jwt-token", response.token()); + assertEquals(99L, response.sessionId()); + verify(fixture.authCacheService).storeMemberRefreshToken(any(String.class), eq(5L)); + verify(fixture.cacheService).set( + eq(DesktopOAuthConstants.REFRESH_SESSION_CACHE), + any(String.class), + eq(99L), + eq(AuthCacheConstant.MEMBER_REFRESH_TOKEN_EXPIRE_HOURS), + eq(TimeUnit.HOURS) + ); + } + + @Test + void refreshShouldRotateRefreshTokenAndTouchSession() { + TestFixture fixture = new TestFixture(); + Member member = member(); + MemberClientSession session = session(); + when(fixture.authCacheService.getMemberIdByRefreshToken(any(String.class))).thenReturn(5L); + when(fixture.cacheService.get(eq(DesktopOAuthConstants.REFRESH_SESSION_CACHE), any(String.class), eq(Long.class))) + .thenReturn(99L); + when(fixture.clientSessionService.touchActiveSession(99L)).thenReturn(session); + when(fixture.memberService.getMemberById(5L)).thenReturn(member); + when(fixture.jwtTokenUtil.generateToken(eq("ronger"), anyMap())).thenReturn("new-jwt"); + when(fixture.jwtTokenUtil.getTokenPrefix()).thenReturn("Bearer "); + + DesktopTokenResponse response = fixture.service.token(new DesktopTokenRequest( + DesktopOAuthConstants.GRANT_TYPE_REFRESH_TOKEN, + null, + null, + null, + "old-refresh" + )); + + assertEquals("new-jwt", response.token()); + verify(fixture.authCacheService).removeMemberRefreshToken(any(String.class)); + verify(fixture.authCacheService).storeMemberRefreshToken(any(String.class), eq(5L)); + } + + @Test + void logoutShouldNotRevokeSessionIdWithoutCurrentUser() { + TestFixture fixture = new TestFixture(); + when(fixture.clientSessionService.getById(99L)).thenReturn(session()); + + Boolean result = fixture.service.logout(null, null, 99L); + + assertEquals(true, result); + verify(fixture.clientSessionService, never()).revokeSession(99L); + } + + @Test + void logoutShouldRevokeSessionByRefreshTokenWithoutCurrentUser() { + TestFixture fixture = new TestFixture(); + when(fixture.cacheService.get(eq(DesktopOAuthConstants.REFRESH_SESSION_CACHE), any(String.class), eq(Long.class))) + .thenReturn(99L); + + Boolean result = fixture.service.logout(null, "refresh-token", null); + + assertEquals(true, result); + verify(fixture.clientSessionService).revokeSession(99L); + verify(fixture.authCacheService).removeMemberRefreshToken(any(String.class)); + } + + @Test + void revokeSessionShouldRemoveIndexedRefreshToken() { + TestFixture fixture = new TestFixture(); + when(fixture.clientSessionService.revokeMemberSession(5L, 99L)).thenReturn(true); + when(fixture.cacheService.get(DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, "99", String.class)) + .thenReturn("refresh-hash"); + + Boolean result = fixture.service.revokeSession(5L, 99L); + + assertEquals(true, result); + verify(fixture.authCacheService).removeMemberRefreshToken("refresh-hash"); + verify(fixture.cacheService).delete(DesktopOAuthConstants.REFRESH_SESSION_CACHE, "refresh-hash"); + verify(fixture.cacheService).delete(DesktopOAuthConstants.SESSION_REFRESH_INDEX_CACHE, "99"); + } + + private static CurrentUser currentUser(Long userId) { + CurrentUser currentUser = mock(CurrentUser.class); + when(currentUser.getUserId()).thenReturn(userId); + when(currentUser.isAuthenticated()).thenReturn(true); + return currentUser; + } + + private static Member member() { + Member member = new Member(); + member.setId(5L); + member.setUsername("ronger"); + member.setNickname("Ronger"); + member.setAvatarUrl("https://static.rymcu.com/avatar.png"); + return member; + } + + private static MemberClientSession session() { + MemberClientSession session = new MemberClientSession(); + session.setId(99L); + session.setMemberId(5L); + session.setClientId(DesktopOAuthConstants.RODAK_CLIENT_ID); + session.setStatus(DesktopOAuthConstants.SESSION_STATUS_ACTIVE); + return session; + } + + private static String challengeOf(String verifier) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String payloadJson(DesktopAuthorizationCodePayload payload) { + try { + return objectMapper().writeValueAsString(payload); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + return mapper; + } + + private static final class TestFixture { + private final ApiMemberService memberService = mock(ApiMemberService.class); + private final MemberClientSessionService clientSessionService = mock(MemberClientSessionService.class); + private final AuthCacheService authCacheService = mock(AuthCacheService.class); + private final CacheService cacheService = mock(CacheService.class); + private final JwtTokenUtil jwtTokenUtil = mock(JwtTokenUtil.class); + private final DesktopOAuthServiceImpl service = new DesktopOAuthServiceImpl( + memberService, + clientSessionService, + authCacheService, + cacheService, + jwtTokenUtil, + objectMapper() + ); + } +} diff --git a/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java b/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java new file mode 100644 index 00000000..f7f2f243 --- /dev/null +++ b/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java @@ -0,0 +1,61 @@ +package com.rymcu.mortise.member.service; + +import com.mybatisflex.core.service.IService; +import com.rymcu.mortise.member.entity.MemberClientSession; + +import java.util.List; + +/** + * 会员客户端会话服务。 + * + * @author ronger + */ +public interface MemberClientSessionService extends IService { + + /** + * 创建桌面客户端会话。 + * + * @param memberId 会员ID + * @param clientId 客户端ID + * @param deviceName 设备名称 + * @param deviceFingerprintHash 设备指纹哈希 + * @return 会话 + */ + MemberClientSession createSession(Long memberId, + String clientId, + String deviceName, + String deviceFingerprintHash); + + /** + * 标记会话活跃。 + * + * @param sessionId 会话ID + * @return 会话,不存在或已撤销时返回 null + */ + MemberClientSession touchActiveSession(Long sessionId); + + /** + * 查询会员客户端会话。 + * + * @param memberId 会员ID + * @return 会话列表 + */ + List listMemberSessions(Long memberId); + + /** + * 撤销会话。 + * + * @param sessionId 会话ID + * @return 是否撤销成功 + */ + Boolean revokeSession(Long sessionId); + + /** + * 撤销会员自己的会话。 + * + * @param memberId 会员ID + * @param sessionId 会话ID + * @return 是否撤销成功 + */ + Boolean revokeMemberSession(Long memberId, Long sessionId); +} diff --git a/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/impl/MemberClientSessionServiceImpl.java b/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/impl/MemberClientSessionServiceImpl.java new file mode 100644 index 00000000..c808083e --- /dev/null +++ b/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/impl/MemberClientSessionServiceImpl.java @@ -0,0 +1,122 @@ +package com.rymcu.mortise.member.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import com.rymcu.mortise.common.enumerate.DelFlag; +import com.rymcu.mortise.member.constant.DesktopOAuthConstants; +import com.rymcu.mortise.member.entity.MemberClientSession; +import com.rymcu.mortise.member.mapper.MemberClientSessionMapper; +import com.rymcu.mortise.member.service.MemberClientSessionService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static com.rymcu.mortise.member.entity.table.MemberClientSessionTableDef.MEMBER_CLIENT_SESSION; + +/** + * 会员客户端会话服务实现。 + * + * @author ronger + */ +@Slf4j +@Service +public class MemberClientSessionServiceImpl + extends ServiceImpl + implements MemberClientSessionService { + + @Override + @Transactional(rollbackFor = Exception.class) + public MemberClientSession createSession(Long memberId, + String clientId, + String deviceName, + String deviceFingerprintHash) { + LocalDateTime now = LocalDateTime.now(); + MemberClientSession session = new MemberClientSession(); + session.setMemberId(memberId); + session.setClientId(clientId); + session.setDeviceName(deviceName); + session.setDeviceFingerprintHash(deviceFingerprintHash); + session.setStatus(DesktopOAuthConstants.SESSION_STATUS_ACTIVE); + session.setLastActiveAt(now); + session.setCreatedTime(now); + session.setUpdatedTime(now); + session.setDelFlag(DelFlag.NORMAL.ordinal()); + save(session); + log.info("创建会员客户端会话: sessionId={}, memberId={}, clientId={}", + session.getId(), memberId, clientId); + return session; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public MemberClientSession touchActiveSession(Long sessionId) { + if (sessionId == null) { + return null; + } + + MemberClientSession session = getById(sessionId); + if (session == null + || !DesktopOAuthConstants.SESSION_STATUS_ACTIVE.equals(session.getStatus())) { + return null; + } + + session.setLastActiveAt(LocalDateTime.now()); + session.setUpdatedTime(LocalDateTime.now()); + updateById(session); + return session; + } + + @Override + public List listMemberSessions(Long memberId) { + if (memberId == null) { + return Collections.emptyList(); + } + + return list(QueryWrapper.create() + .where(MEMBER_CLIENT_SESSION.MEMBER_ID.eq(memberId)) + .and(MEMBER_CLIENT_SESSION.DEL_FLAG.eq(DelFlag.NORMAL.ordinal())) + .orderBy(MEMBER_CLIENT_SESSION.LAST_ACTIVE_AT.desc(), MEMBER_CLIENT_SESSION.CREATED_TIME.desc())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean revokeSession(Long sessionId) { + if (sessionId == null) { + return false; + } + + MemberClientSession session = getById(sessionId); + if (session == null) { + return false; + } + + session.setStatus(DesktopOAuthConstants.SESSION_STATUS_REVOKED); + session.setRevokedAt(LocalDateTime.now()); + session.setUpdatedTime(LocalDateTime.now()); + boolean updated = updateById(session); + if (updated) { + log.info("撤销会员客户端会话: sessionId={}, memberId={}, clientId={}", + session.getId(), session.getMemberId(), session.getClientId()); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean revokeMemberSession(Long memberId, Long sessionId) { + if (memberId == null || sessionId == null) { + return false; + } + + MemberClientSession session = getById(sessionId); + if (session == null || !memberId.equals(session.getMemberId())) { + return false; + } + + return revokeSession(sessionId); + } +} diff --git a/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/DesktopOAuthConstants.java b/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/DesktopOAuthConstants.java new file mode 100644 index 00000000..24e96d8f --- /dev/null +++ b/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/DesktopOAuthConstants.java @@ -0,0 +1,24 @@ +package com.rymcu.mortise.member.constant; + +/** + * 桌面端授权桥常量。 + * + * @author ronger + */ +public final class DesktopOAuthConstants { + + private DesktopOAuthConstants() { + } + + public static final String RODAK_CLIENT_ID = "rodak-desktop"; + public static final String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"; + public static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; + public static final String CODE_CHALLENGE_METHOD_S256 = "S256"; + public static final String DEFAULT_SCOPE = "profile"; + public static final String SESSION_STATUS_ACTIVE = "active"; + public static final String SESSION_STATUS_REVOKED = "revoked"; + public static final String AUTHORIZATION_CODE_CACHE = "desktop:oauth:code"; + public static final String REFRESH_SESSION_CACHE = "desktop:oauth:refresh-session"; + public static final String SESSION_REFRESH_INDEX_CACHE = "desktop:oauth:session-refresh"; + public static final long AUTHORIZATION_CODE_EXPIRE_MINUTES = 5; +} diff --git a/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/MemberJwtConstants.java b/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/MemberJwtConstants.java index bd6314ec..0ec5cacf 100644 --- a/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/MemberJwtConstants.java +++ b/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/constant/MemberJwtConstants.java @@ -16,11 +16,17 @@ private MemberJwtConstants() { public static final String CLAIM_LOGIN_TYPE = "loginType"; public static final String CLAIM_OPENID = "openid"; public static final String CLAIM_UNIONID = "unionid"; + public static final String CLAIM_SESSION_ID = "sid"; + public static final String CLAIM_CLIENT_ID = "clientId"; + public static final String CLAIM_LOGIN_CHANNEL = "loginChannel"; + public static final String CLAIM_SCOPE = "scope"; // ── claim values ── public static final String TYPE_MEMBER = "member"; public static final String LOGIN_TYPE_PHONE = "phone"; public static final String LOGIN_TYPE_OAUTH2 = "oauth2"; + public static final String LOGIN_TYPE_DESKTOP = "desktop"; + public static final String LOGIN_CHANNEL_DESKTOP_BROWSER = "desktop_browser"; // ── token 过期时间(毫秒) ── /** 访问令牌过期时间:30 分钟 */ diff --git a/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/entity/MemberClientSession.java b/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/entity/MemberClientSession.java new file mode 100644 index 00000000..31537c94 --- /dev/null +++ b/mortise-member/mortise-member-domain/src/main/java/com/rymcu/mortise/member/entity/MemberClientSession.java @@ -0,0 +1,52 @@ +package com.rymcu.mortise.member.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 会员客户端会话。 + * + * @author ronger + */ +@Data +@Table(value = "mortise_member_client_session", schema = "mortise") +public class MemberClientSession implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = KeyGenerators.flexId) + private Long id; + + private Long memberId; + + private String clientId; + + private String deviceName; + + private String deviceFingerprintHash; + + private String status; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime lastActiveAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime revokedAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedTime; + + @Column(isLogicDelete = true) + private Integer delFlag; +} diff --git a/mortise-member/mortise-member-infra/src/main/java/com/rymcu/mortise/member/mapper/MemberClientSessionMapper.java b/mortise-member/mortise-member-infra/src/main/java/com/rymcu/mortise/member/mapper/MemberClientSessionMapper.java new file mode 100644 index 00000000..1757ba54 --- /dev/null +++ b/mortise-member/mortise-member-infra/src/main/java/com/rymcu/mortise/member/mapper/MemberClientSessionMapper.java @@ -0,0 +1,14 @@ +package com.rymcu.mortise.member.mapper; + +import com.mybatisflex.core.BaseMapper; +import com.rymcu.mortise.member.entity.MemberClientSession; +import org.apache.ibatis.annotations.Mapper; + +/** + * 会员客户端会话 Mapper。 + * + * @author ronger + */ +@Mapper +public interface MemberClientSessionMapper extends BaseMapper { +} diff --git a/mortise-member/mortise-member-infra/src/main/resources/db/migration/V440__Create_Member_Client_Session_Table.sql b/mortise-member/mortise-member-infra/src/main/resources/db/migration/V440__Create_Member_Client_Session_Table.sql new file mode 100644 index 00000000..538e583c --- /dev/null +++ b/mortise-member/mortise-member-infra/src/main/resources/db/migration/V440__Create_Member_Client_Session_Table.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS mortise.mortise_member_client_session +( + id BIGINT PRIMARY KEY, + member_id BIGINT NOT NULL REFERENCES mortise.mortise_member (id), + client_id VARCHAR(64) NOT NULL, + device_name VARCHAR(120), + device_fingerprint_hash VARCHAR(128), + status VARCHAR(20) NOT NULL DEFAULT 'active', + last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP, + created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + del_flag INTEGER NOT NULL DEFAULT 0 +); + +COMMENT ON TABLE mortise.mortise_member_client_session IS '会员客户端会话表'; +COMMENT ON COLUMN mortise.mortise_member_client_session.member_id IS '会员ID'; +COMMENT ON COLUMN mortise.mortise_member_client_session.client_id IS '客户端ID,如 rodak-desktop'; +COMMENT ON COLUMN mortise.mortise_member_client_session.device_name IS '客户端设备名称'; +COMMENT ON COLUMN mortise.mortise_member_client_session.device_fingerprint_hash IS '设备指纹哈希'; +COMMENT ON COLUMN mortise.mortise_member_client_session.status IS '会话状态:active/revoked'; +COMMENT ON COLUMN mortise.mortise_member_client_session.last_active_at IS '最近活跃时间'; +COMMENT ON COLUMN mortise.mortise_member_client_session.revoked_at IS '撤销时间'; +COMMENT ON COLUMN mortise.mortise_member_client_session.del_flag IS '删除标记:0-未删除, 1-已删除'; + +CREATE INDEX IF NOT EXISTS idx_member_client_session_member + ON mortise.mortise_member_client_session (member_id, status) + WHERE del_flag = 0; + +CREATE INDEX IF NOT EXISTS idx_member_client_session_client + ON mortise.mortise_member_client_session (client_id, status) + WHERE del_flag = 0; + +DROP TRIGGER IF EXISTS update_mortise_member_client_session_updated_time + ON mortise.mortise_member_client_session; +CREATE TRIGGER update_mortise_member_client_session_updated_time + BEFORE UPDATE + ON mortise.mortise_member_client_session + FOR EACH ROW EXECUTE FUNCTION update_updated_time_column(); From ff31631d6939c9f0199fccebd20e6a92d4ff0a61 Mon Sep 17 00:00:00 2001 From: ronger Date: Sun, 10 May 2026 21:55:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Rodak=20?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E8=AF=84=E5=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/apps/site/app/pages/security.vue | 12 ++++- .../standalone/app/pages/security.vue | 12 ++++- .../controller/MemberSecurityController.java | 48 +++++++++++++++++-- .../service/impl/DesktopOAuthServiceImpl.java | 5 +- .../impl/DesktopOAuthServiceImplTest.java | 17 ++++++- .../service/MemberClientSessionService.java | 4 +- .../impl/MemberClientSessionServiceImpl.java | 5 +- .../member/entity/MemberClientSession.java | 2 + .../V441__Add_Member_Client_Session_Scope.sql | 4 ++ 9 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 mortise-member/mortise-member-infra/src/main/resources/db/migration/V441__Add_Member_Client_Session_Scope.sql diff --git a/frontend/apps/site/app/pages/security.vue b/frontend/apps/site/app/pages/security.vue index af751455..10a60b9e 100644 --- a/frontend/apps/site/app/pages/security.vue +++ b/frontend/apps/site/app/pages/security.vue @@ -43,13 +43,23 @@ function formatTime(value?: string | null): string { if (!value) { return '暂无记录' } + const date = parseBackendDateTime(value) + if (!date) { + return '暂无记录' + } return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' - }).format(new Date(value)) + }).format(date) +} + +function parseBackendDateTime(value: string): Date | null { + const normalized = value.trim().replace(' ', 'T') + const date = new Date(normalized) + return Number.isNaN(date.getTime()) ? null : date } function statusLabel(status: string): string { diff --git a/frontend/templates/standalone/app/pages/security.vue b/frontend/templates/standalone/app/pages/security.vue index 86d1884b..f41e3834 100644 --- a/frontend/templates/standalone/app/pages/security.vue +++ b/frontend/templates/standalone/app/pages/security.vue @@ -31,13 +31,23 @@ function formatTime(value?: string | null): string { if (!value) { return '暂无记录' } + const date = parseBackendDateTime(value) + if (!date) { + return '暂无记录' + } return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' - }).format(new Date(value)) + }).format(date) +} + +function parseBackendDateTime(value: string): Date | null { + const normalized = value.trim().replace(' ', 'T') + const date = new Date(normalized) + return Number.isNaN(date.getTime()) ? null : date } function statusLabel(status: string): string { diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java index 06af3eec..f9b76e31 100644 --- a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/controller/MemberSecurityController.java @@ -1,5 +1,6 @@ package com.rymcu.mortise.member.api.controller; +import com.rymcu.mortise.auth.util.JwtTokenUtil; import com.rymcu.mortise.common.exception.BusinessException; import com.rymcu.mortise.core.model.CurrentUser; import com.rymcu.mortise.core.result.GlobalResult; @@ -7,11 +8,13 @@ import com.rymcu.mortise.member.api.model.MemberClientSessionResponse; import com.rymcu.mortise.member.api.service.DesktopOAuthService; import com.rymcu.mortise.member.constant.DesktopOAuthConstants; +import com.rymcu.mortise.member.constant.MemberJwtConstants; import com.rymcu.mortise.member.entity.MemberClientSession; import com.rymcu.mortise.member.service.MemberClientSessionService; import com.rymcu.mortise.web.annotation.ApiController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -21,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import java.util.List; +import java.util.Objects; /** * 会员账号安全中心 Controller。 @@ -35,16 +39,19 @@ public class MemberSecurityController { private final MemberClientSessionService clientSessionService; private final DesktopOAuthService desktopOAuthService; + private final JwtTokenUtil jwtTokenUtil; @GetMapping("/client-sessions") @Operation(summary = "查询桌面客户端会话") @ApiLog(recordParams = false, recordResponseBody = false, value = "查询会员客户端会话") public GlobalResult> listClientSessions( - @AuthenticationPrincipal CurrentUser currentUser) { + @AuthenticationPrincipal CurrentUser currentUser, + HttpServletRequest request) { Long memberId = requireMemberId(currentUser); + Long currentSessionId = resolveCurrentSessionId(request); List sessions = clientSessionService.listMemberSessions(memberId) .stream() - .map(this::toResponse) + .map(session -> toResponse(session, currentSessionId)) .toList(); return GlobalResult.success(sessions); } @@ -69,7 +76,7 @@ private Long requireMemberId(CurrentUser currentUser) { return currentUser.getUserId(); } - private MemberClientSessionResponse toResponse(MemberClientSession session) { + private MemberClientSessionResponse toResponse(MemberClientSession session, Long currentSessionId) { return new MemberClientSessionResponse( session.getId(), session.getClientId(), @@ -79,7 +86,7 @@ private MemberClientSessionResponse toResponse(MemberClientSession session) { session.getLastActiveAt(), session.getRevokedAt(), session.getCreatedTime(), - false + Objects.equals(session.getId(), currentSessionId) ); } @@ -89,4 +96,37 @@ private String resolveClientName(String clientId) { } return StringUtils.defaultIfBlank(clientId, "未知客户端"); } + + private Long resolveCurrentSessionId(HttpServletRequest request) { + String token = extractToken(request); + if (StringUtils.isBlank(token)) { + return null; + } + Object sessionId = jwtTokenUtil.getClaimFromToken( + token, + claims -> claims.get(MemberJwtConstants.CLAIM_SESSION_ID) + ); + if (sessionId instanceof Number number) { + return number.longValue(); + } + if (sessionId instanceof String value && StringUtils.isNumeric(value)) { + return Long.valueOf(value); + } + return null; + } + + private String extractToken(HttpServletRequest request) { + String headerValue = request.getHeader(jwtTokenUtil.getTokenHeader()); + if (StringUtils.isBlank(headerValue)) { + return null; + } + + String token = headerValue.trim(); + String prefix = StringUtils.defaultIfBlank(StringUtils.trimToNull(jwtTokenUtil.getTokenPrefix()), "Bearer"); + while (token.length() > prefix.length() + && token.regionMatches(true, 0, prefix, 0, prefix.length())) { + token = token.substring(prefix.length()).trim(); + } + return StringUtils.trimToNull(token); + } } diff --git a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java index 88f6632d..e5f9b0da 100644 --- a/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java +++ b/mortise-member/mortise-member-api/src/main/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImpl.java @@ -205,7 +205,8 @@ private DesktopTokenResponse exchangeAuthorizationCode(DesktopTokenRequest reque member.getId(), payload.clientId(), payload.deviceName(), - payload.deviceFingerprintHash() + payload.deviceFingerprintHash(), + payload.scope() ); return buildTokenResponse(member, session, payload.scope(), null); @@ -232,7 +233,7 @@ private DesktopTokenResponse refreshDesktopToken(DesktopTokenRequest request) { } Member member = getActiveMember(memberId); - return buildTokenResponse(member, session, DesktopOAuthConstants.DEFAULT_SCOPE, request.refreshToken()); + return buildTokenResponse(member, session, session.getScope(), request.refreshToken()); } private DesktopTokenResponse buildTokenResponse(Member member, diff --git a/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java b/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java index 3144eea1..3b882835 100644 --- a/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java +++ b/mortise-member/mortise-member-api/src/test/java/com/rymcu/mortise/member/api/service/impl/DesktopOAuthServiceImplTest.java @@ -13,10 +13,12 @@ import com.rymcu.mortise.member.api.model.DesktopTokenResponse; import com.rymcu.mortise.member.api.service.ApiMemberService; import com.rymcu.mortise.member.constant.DesktopOAuthConstants; +import com.rymcu.mortise.member.constant.MemberJwtConstants; import com.rymcu.mortise.member.entity.Member; import com.rymcu.mortise.member.entity.MemberClientSession; import com.rymcu.mortise.member.service.MemberClientSessionService; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -132,13 +134,19 @@ void tokenShouldExchangeAuthorizationCodeAndCreateClientSession() { DesktopOAuthConstants.RODAK_CLIENT_ID, "http://127.0.0.1:49152/auth/callback", challengeOf("verifier"), - "profile", + "profile offline_access", "Dev PC", "device-hash", LocalDateTime.now().plusMinutes(5) ))); when(fixture.memberService.getMemberById(5L)).thenReturn(member); - when(fixture.clientSessionService.createSession(5L, DesktopOAuthConstants.RODAK_CLIENT_ID, "Dev PC", "device-hash")) + when(fixture.clientSessionService.createSession( + 5L, + DesktopOAuthConstants.RODAK_CLIENT_ID, + "Dev PC", + "device-hash", + "profile offline_access" + )) .thenReturn(session); when(fixture.jwtTokenUtil.generateToken(eq("ronger"), anyMap())).thenReturn("jwt-token"); when(fixture.jwtTokenUtil.getTokenPrefix()).thenReturn("Bearer "); @@ -169,6 +177,7 @@ void refreshShouldRotateRefreshTokenAndTouchSession() { TestFixture fixture = new TestFixture(); Member member = member(); MemberClientSession session = session(); + session.setScope("profile offline_access"); when(fixture.authCacheService.getMemberIdByRefreshToken(any(String.class))).thenReturn(5L); when(fixture.cacheService.get(eq(DesktopOAuthConstants.REFRESH_SESSION_CACHE), any(String.class), eq(Long.class))) .thenReturn(99L); @@ -186,6 +195,9 @@ void refreshShouldRotateRefreshTokenAndTouchSession() { )); assertEquals("new-jwt", response.token()); + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(fixture.jwtTokenUtil).generateToken(eq("ronger"), claimsCaptor.capture()); + assertEquals("profile offline_access", claimsCaptor.getValue().get(MemberJwtConstants.CLAIM_SCOPE)); verify(fixture.authCacheService).removeMemberRefreshToken(any(String.class)); verify(fixture.authCacheService).storeMemberRefreshToken(any(String.class), eq(5L)); } @@ -250,6 +262,7 @@ private static MemberClientSession session() { session.setId(99L); session.setMemberId(5L); session.setClientId(DesktopOAuthConstants.RODAK_CLIENT_ID); + session.setScope(DesktopOAuthConstants.DEFAULT_SCOPE); session.setStatus(DesktopOAuthConstants.SESSION_STATUS_ACTIVE); return session; } diff --git a/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java b/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java index f7f2f243..7ede39a3 100644 --- a/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java +++ b/mortise-member/mortise-member-application/src/main/java/com/rymcu/mortise/member/service/MemberClientSessionService.java @@ -19,12 +19,14 @@ public interface MemberClientSessionService extends IService