From 17b2ba49a89109274c7d3b5f537e94eb78bbdc13 Mon Sep 17 00:00:00 2001 From: Kroytz Date: Sun, 19 Jul 2026 00:25:39 +0800 Subject: [PATCH] fix(auth): move admin session to HttpOnly cookies --- .../pom.xml | 6 ++ .../security/config/SecurityProperties.java | 6 ++ .../core/util/SecurityFrameworkUtils.java | 9 ++- .../core/util/SecurityFrameworkUtilsTest.java | 21 ++++++ .../controller/admin/auth/AuthController.java | 66 ++++++++++++++++++- .../src/main/resources/application-local.yaml | 1 + yshop-drink-vue3/src/api/login/index.ts | 9 ++- yshop-drink-vue3/src/api/login/types.ts | 10 --- .../src/components/Editor/src/Editor.vue | 4 +- .../components/Materials/src/Materials.vue | 7 +- .../Materials/src/editorMaterials.vue | 7 +- yshop-drink-vue3/src/config/axios/service.ts | 40 ++++------- .../src/directives/permission/hasPermi.ts | 5 +- .../src/directives/permission/hasRole.ts | 5 +- yshop-drink-vue3/src/hooks/web/useCache.ts | 10 --- .../UserInfo/src/components/LockPage.vue | 2 - yshop-drink-vue3/src/permission.ts | 6 +- .../src/store/modules/permission.ts | 9 +-- yshop-drink-vue3/src/store/modules/user.ts | 43 +++++------- yshop-drink-vue3/src/utils/auth.ts | 38 +++-------- yshop-drink-vue3/src/utils/permission.ts | 8 +-- .../src/views/Home/PanelGroupT.vue | 8 +-- .../src/views/Login/SocialLogin.vue | 6 +- .../src/views/Login/components/LoginForm.vue | 9 +-- .../src/views/Login/components/MobileForm.vue | 6 +- .../src/views/infra/webSocket/index.vue | 5 +- .../src/views/mall/store/shop/ShopForm.vue | 9 +-- .../wx-reply/components/TabImage.vue | 4 +- .../wx-reply/components/TabMusic.vue | 4 +- .../wx-reply/components/TabVideo.vue | 4 +- .../wx-reply/components/TabVoice.vue | 4 +- .../views/mp/draft/components/CoverSelect.vue | 4 +- .../src/views/mp/draft/editor-config.ts | 4 +- .../mp/material/components/UploadFile.vue | 11 +--- .../mp/material/components/UploadVideo.vue | 4 +- .../views/mp/material/components/upload.ts | 3 - .../src/views/system/menu/MenuForm.vue | 4 -- .../src/views/system/menu/index.vue | 5 -- .../src/views/system/user/UserImportForm.vue | 4 +- 39 files changed, 191 insertions(+), 219 deletions(-) create mode 100644 yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/test/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtilsTest.java diff --git a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/pom.xml b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/pom.xml index 0cb7ea3..286f0f8 100644 --- a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/pom.xml +++ b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/pom.xml @@ -60,6 +60,12 @@ bizlog-sdk + + org.springframework.boot + spring-boot-starter-test + test + + co.yixiang.boot diff --git a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/config/SecurityProperties.java b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/config/SecurityProperties.java index 3f24798..e3cc886 100644 --- a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/config/SecurityProperties.java +++ b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/config/SecurityProperties.java @@ -48,4 +48,10 @@ public class SecurityProperties { * PasswordEncoder 加密复杂度,越高开销越大 */ private Integer passwordEncoderLength = 4; + + /** + * 管理端认证 Cookie 是否仅通过 HTTPS 发送 + */ + @NotNull(message = "Cookie Secure 开关不能为空") + private Boolean cookieSecure = true; } diff --git a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtils.java b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtils.java index b60b0c3..a7cfa8a 100644 --- a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtils.java +++ b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/main/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtils.java @@ -11,6 +11,7 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.util.StringUtils; +import org.springframework.web.util.WebUtils; import jakarta.servlet.http.HttpServletRequest; import java.util.Collections; @@ -22,6 +23,9 @@ import java.util.Collections; */ public class SecurityFrameworkUtils { + public static final String ACCESS_TOKEN_COOKIE = "yshop_admin_access_token"; + public static final String REFRESH_TOKEN_COOKIE = "yshop_admin_refresh_token"; + /** * HEADER 认证头 value 的前缀 */ @@ -39,11 +43,14 @@ public class SecurityFrameworkUtils { */ public static String obtainAuthorization(HttpServletRequest request, String headerName, String parameterName) { - // 1. 获得 Token。优先级:Header > Parameter + // 1. 获得 Token。优先级:Header > Parameter > HttpOnly Cookie String token = request.getHeader(headerName); if (StrUtil.isEmpty(token)) { token = request.getParameter(parameterName); } + if (StrUtil.isEmpty(token) && WebUtils.getCookie(request, ACCESS_TOKEN_COOKIE) != null) { + token = WebUtils.getCookie(request, ACCESS_TOKEN_COOKIE).getValue(); + } if (!StringUtils.hasText(token)) { return null; } diff --git a/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/test/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtilsTest.java b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/test/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtilsTest.java new file mode 100644 index 0000000..0fed7d7 --- /dev/null +++ b/yshop-drink-boot3/yshop-framework/yshop-spring-boot-starter-security/src/test/java/co/yixiang/yshop/framework/security/core/util/SecurityFrameworkUtilsTest.java @@ -0,0 +1,21 @@ +package co.yixiang.yshop.framework.security.core.util; + +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SecurityFrameworkUtilsTest { + + @Test + void obtainAuthorizationUsesCookieWhenHeaderIsMissing() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(SecurityFrameworkUtils.ACCESS_TOKEN_COOKIE, "cookie-token")); + + assertEquals("cookie-token", SecurityFrameworkUtils.obtainAuthorization(request, "Authorization", "token")); + + request.addHeader("Authorization", "Bearer header-token"); + assertEquals("header-token", SecurityFrameworkUtils.obtainAuthorization(request, "Authorization", "token")); + } +} diff --git a/yshop-drink-boot3/yshop-module-system/yshop-module-system-biz/src/main/java/co/yixiang/yshop/module/system/controller/admin/auth/AuthController.java b/yshop-drink-boot3/yshop-module-system/yshop-module-system-biz/src/main/java/co/yixiang/yshop/module/system/controller/admin/auth/AuthController.java index 5c9a3ff..5dbd34d 100644 --- a/yshop-drink-boot3/yshop-module-system/yshop-module-system-biz/src/main/java/co/yixiang/yshop/module/system/controller/admin/auth/AuthController.java +++ b/yshop-drink-boot3/yshop-module-system/yshop-module-system-biz/src/main/java/co/yixiang/yshop/module/system/controller/admin/auth/AuthController.java @@ -24,19 +24,28 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.WebUtils; import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Set; +import static co.yixiang.yshop.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static co.yixiang.yshop.framework.common.exception.util.ServiceExceptionUtil.exception; import static co.yixiang.yshop.framework.common.pojo.CommonResult.success; import static co.yixiang.yshop.framework.common.util.collection.CollectionUtils.convertSet; +import static co.yixiang.yshop.framework.security.core.util.SecurityFrameworkUtils.ACCESS_TOKEN_COOKIE; +import static co.yixiang.yshop.framework.security.core.util.SecurityFrameworkUtils.REFRESH_TOKEN_COOKIE; import static co.yixiang.yshop.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; @Tag(name = "管理后台 - 认证") @@ -69,15 +78,25 @@ public class AuthController { return success(authService.login(reqVO)); } + @PostMapping("/login-cookie") + @PermitAll + @Operation(summary = "使用账号密码登录(HttpOnly Cookie)") + public CommonResult loginCookie(@RequestBody @Valid AuthLoginReqVO reqVO, + HttpServletResponse response) { + writeTokenCookies(response, authService.login(reqVO)); + return success(true); + } + @PostMapping("/logout") @PermitAll @Operation(summary = "登出系统") - public CommonResult logout(HttpServletRequest request) { + public CommonResult logout(HttpServletRequest request, HttpServletResponse response) { String token = SecurityFrameworkUtils.obtainAuthorization(request, securityProperties.getTokenHeader(), securityProperties.getTokenParameter()); if (StrUtil.isNotBlank(token)) { authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType()); } + clearTokenCookies(response); return success(true); } @@ -89,6 +108,18 @@ public class AuthController { return success(authService.refreshToken(refreshToken)); } + @PostMapping("/refresh-token-cookie") + @PermitAll + @Operation(summary = "刷新令牌(HttpOnly Cookie)") + public CommonResult refreshTokenCookie(HttpServletRequest request, HttpServletResponse response) { + if (WebUtils.getCookie(request, REFRESH_TOKEN_COOKIE) == null) { + throw exception(UNAUTHORIZED); + } + writeTokenCookies(response, + authService.refreshToken(WebUtils.getCookie(request, REFRESH_TOKEN_COOKIE).getValue())); + return success(true); + } + @GetMapping("/get-permission-info") @Operation(summary = "获取登录用户的权限信息") public CommonResult getPermissionInfo() { @@ -124,6 +155,15 @@ public class AuthController { return success(authService.smsLogin(reqVO)); } + @PostMapping("/sms-login-cookie") + @PermitAll + @Operation(summary = "使用短信验证码登录(HttpOnly Cookie)") + public CommonResult smsLoginCookie(@RequestBody @Valid AuthSmsLoginReqVO reqVO, + HttpServletResponse response) { + writeTokenCookies(response, authService.smsLogin(reqVO)); + return success(true); + } + @PostMapping("/send-sms-code") @PermitAll @Operation(summary = "发送手机验证码") @@ -154,4 +194,28 @@ public class AuthController { return success(authService.socialLogin(reqVO)); } + @PostMapping("/social-login-cookie") + @PermitAll + @Operation(summary = "社交快捷登录(HttpOnly Cookie)") + public CommonResult socialQuickLoginCookie(@RequestBody @Valid AuthSocialLoginReqVO reqVO, + HttpServletResponse response) { + writeTokenCookies(response, authService.socialLogin(reqVO)); + return success(true); + } + + private void writeTokenCookies(HttpServletResponse response, AuthLoginRespVO token) { + response.addHeader(HttpHeaders.SET_COOKIE, tokenCookie(ACCESS_TOKEN_COOKIE, token.getAccessToken()).toString()); + response.addHeader(HttpHeaders.SET_COOKIE, tokenCookie(REFRESH_TOKEN_COOKIE, token.getRefreshToken()).toString()); + } + + private void clearTokenCookies(HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, tokenCookie(ACCESS_TOKEN_COOKIE, "").maxAge(Duration.ZERO).toString()); + response.addHeader(HttpHeaders.SET_COOKIE, tokenCookie(REFRESH_TOKEN_COOKIE, "").maxAge(Duration.ZERO).toString()); + } + + private ResponseCookie.ResponseCookieBuilder tokenCookie(String name, String value) { + return ResponseCookie.from(name, value).httpOnly(true).secure(securityProperties.getCookieSecure()) + .sameSite("Strict").path("/"); + } + } diff --git a/yshop-drink-boot3/yshop-server/src/main/resources/application-local.yaml b/yshop-drink-boot3/yshop-server/src/main/resources/application-local.yaml index 2935a8f..e89707c 100644 --- a/yshop-drink-boot3/yshop-server/src/main/resources/application-local.yaml +++ b/yshop-drink-boot3/yshop-server/src/main/resources/application-local.yaml @@ -192,6 +192,7 @@ yshop: enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试; security: mock-enable: true + cookie-secure: false xss: enable: false exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系 diff --git a/yshop-drink-vue3/src/api/login/index.ts b/yshop-drink-vue3/src/api/login/index.ts index ef86563..50eebf3 100644 --- a/yshop-drink-vue3/src/api/login/index.ts +++ b/yshop-drink-vue3/src/api/login/index.ts @@ -1,5 +1,4 @@ import request from '@/config/axios' -import { getRefreshToken } from '@/utils/auth' import type { UserLoginVO } from './types' export interface SmsCodeVO { @@ -14,12 +13,12 @@ export interface SmsLoginVO { // 登录 export const login = (data: UserLoginVO) => { - return request.post({ url: '/system/auth/login', data }) + return request.post({ url: '/system/auth/login-cookie', data }) } // 刷新访问令牌 export const refreshToken = () => { - return request.post({ url: '/system/auth/refresh-token?refreshToken=' + getRefreshToken() }) + return request.post({ url: '/system/auth/refresh-token-cookie' }) } // 使用租户名,获得租户编号 @@ -49,13 +48,13 @@ export const sendSmsCode = (data: SmsCodeVO) => { // 短信验证码登录 export const smsLogin = (data: SmsLoginVO) => { - return request.post({ url: '/system/auth/sms-login', data }) + return request.post({ url: '/system/auth/sms-login-cookie', data }) } // 社交快捷登录,使用 code 授权码 export function socialLogin(type: string, code: string, state: string) { return request.post({ - url: '/system/auth/social-login', + url: '/system/auth/social-login-cookie', data: { type, code, diff --git a/yshop-drink-vue3/src/api/login/types.ts b/yshop-drink-vue3/src/api/login/types.ts index fff8122..fbf1eb4 100644 --- a/yshop-drink-vue3/src/api/login/types.ts +++ b/yshop-drink-vue3/src/api/login/types.ts @@ -7,16 +7,6 @@ export type UserLoginVO = { socialState?: string } -export type TokenType = { - id: number // 编号 - accessToken: string // 访问令牌 - refreshToken: string // 刷新令牌 - userId: number // 用户编号 - userType: number //用户类型 - clientId: string //客户端编号 - expiresTime: number //过期时间 -} - export type UserVO = { id: number username: string diff --git a/yshop-drink-vue3/src/components/Editor/src/Editor.vue b/yshop-drink-vue3/src/components/Editor/src/Editor.vue index eff8274..d7705ba 100644 --- a/yshop-drink-vue3/src/components/Editor/src/Editor.vue +++ b/yshop-drink-vue3/src/components/Editor/src/Editor.vue @@ -6,7 +6,7 @@ import { propTypes } from '@/utils/propTypes' import { isNumber } from '@/utils/is' import { ElMessage } from 'element-plus' import { useLocaleStore } from '@/store/modules/locale' -import { getAccessToken, getTenantId } from '@/utils/auth' +import { getTenantId } from '@/utils/auth' defineOptions({ name: 'Editor' }) @@ -104,10 +104,8 @@ const editorConfig = computed((): IEditorConfig => { // 自定义增加 http header headers: { Accept: '*', - Authorization: 'Bearer ' + getAccessToken(), 'tenant-id': getTenantId() }, - // 跨域是否传递 cookie ,默认为 false withCredentials: true, diff --git a/yshop-drink-vue3/src/components/Materials/src/Materials.vue b/yshop-drink-vue3/src/components/Materials/src/Materials.vue index 03a9379..43963f2 100644 --- a/yshop-drink-vue3/src/components/Materials/src/Materials.vue +++ b/yshop-drink-vue3/src/components/Materials/src/Materials.vue @@ -99,7 +99,7 @@ { - // 是否需要设置 token - let isToken = (config!.headers || {}).isToken === false - whiteList.some((v) => { - if (config.url) { - config.url.indexOf(v) > -1 - return (isToken = false) - } - }) - if (getAccessToken() && !isToken) { - ;(config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token - } // 设置租户 if (tenantEnable && tenantEnable === 'true') { const tenantId = getTenantId() @@ -117,16 +102,10 @@ service.interceptors.response.use( // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了 if (!isRefreshToken) { isRefreshToken = true - // 1. 如果获取不到刷新令牌,则只能执行登出操作 - if (!getRefreshToken()) { - return handleAuthorized() - } - // 2. 进行刷新访问令牌 + // 使用 HttpOnly Cookie 刷新访问令牌 try { - const refreshTokenRes = await refreshToken() + await refreshToken() // 2.1 刷新成功,则回放队列的请求 + 当前请求 - setToken((await refreshTokenRes).data.data) - config.headers!.Authorization = 'Bearer ' + getAccessToken() requestList.forEach((cb: any) => { cb() }) @@ -148,7 +127,6 @@ service.interceptors.response.use( // 添加到队列,等待刷新获取到新的令牌 return new Promise((resolve) => { requestList.push(() => { - config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改 resolve(service(config)) }) }) @@ -200,7 +178,12 @@ service.interceptors.response.use( const refreshToken = async () => { axios.defaults.headers.common['tenant-id'] = getTenantId() - return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken()) + const response = await axios.post(base_url + '/system/auth/refresh-token-cookie', null, { + withCredentials: true + }) + if (response.data?.code !== result_code) { + throw new Error(response.data?.msg) + } } const handleAuthorized = () => { const { t } = useI18n() @@ -218,8 +201,7 @@ const handleAuthorized = () => { type: 'warning' }).then(() => { resetRouter() // 重置静态路由表 - deleteUserCache() // 删除用户缓存 - removeToken() + removeAuthenticated() isRelogin.show = false // 干掉token后再走一次路由让它过router.beforeEach的校验 window.location.href = window.location.href diff --git a/yshop-drink-vue3/src/directives/permission/hasPermi.ts b/yshop-drink-vue3/src/directives/permission/hasPermi.ts index d86d2f5..3bdeed8 100644 --- a/yshop-drink-vue3/src/directives/permission/hasPermi.ts +++ b/yshop-drink-vue3/src/directives/permission/hasPermi.ts @@ -1,14 +1,13 @@ import type { App } from 'vue' -import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import { useUserStoreWithOut } from '@/store/modules/user' const { t } = useI18n() // 国际化 export function hasPermi(app: App) { app.directive('hasPermi', (el, binding) => { - const { wsCache } = useCache() const { value } = binding const all_permission = '*:*:*' - const permissions = wsCache.get(CACHE_KEY.USER).permissions + const permissions = useUserStoreWithOut().getPermissions if (value && value instanceof Array && value.length > 0) { const permissionFlag = value diff --git a/yshop-drink-vue3/src/directives/permission/hasRole.ts b/yshop-drink-vue3/src/directives/permission/hasRole.ts index 31a352a..a58774d 100644 --- a/yshop-drink-vue3/src/directives/permission/hasRole.ts +++ b/yshop-drink-vue3/src/directives/permission/hasRole.ts @@ -1,14 +1,13 @@ import type { App } from 'vue' -import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import { useUserStoreWithOut } from '@/store/modules/user' const { t } = useI18n() // 国际化 export function hasRole(app: App) { app.directive('hasRole', (el, binding) => { - const { wsCache } = useCache() const { value } = binding const super_admin = 'admin' - const roles = wsCache.get(CACHE_KEY.USER).roles + const roles = useUserStoreWithOut().getRoles if (value && value instanceof Array && value.length > 0) { const roleFlag = value diff --git a/yshop-drink-vue3/src/hooks/web/useCache.ts b/yshop-drink-vue3/src/hooks/web/useCache.ts index 4f39f30..9573fd5 100644 --- a/yshop-drink-vue3/src/hooks/web/useCache.ts +++ b/yshop-drink-vue3/src/hooks/web/useCache.ts @@ -7,9 +7,6 @@ import WebStorageCache from 'web-storage-cache' type CacheType = 'localStorage' | 'sessionStorage' export const CACHE_KEY = { - // 用户相关 - ROLE_ROUTERS: 'roleRouters', - USER: 'user', // 系统设置 IS_DARK: 'isDark', LANG: 'lang', @@ -30,10 +27,3 @@ export const useCache = (type: CacheType = 'localStorage') => { wsCache } } - -export const deleteUserCache = () => { - const { wsCache } = useCache() - wsCache.delete(CACHE_KEY.USER) - wsCache.delete(CACHE_KEY.ROLE_ROUTERS) - // 注意,不要清理 LoginForm 登录表单 -} diff --git a/yshop-drink-vue3/src/layout/components/UserInfo/src/components/LockPage.vue b/yshop-drink-vue3/src/layout/components/UserInfo/src/components/LockPage.vue index 497dd37..d1cdf47 100644 --- a/yshop-drink-vue3/src/layout/components/UserInfo/src/components/LockPage.vue +++ b/yshop-drink-vue3/src/layout/components/UserInfo/src/components/LockPage.vue @@ -1,6 +1,5 @@