diff --git a/docs/platform/topics/identity-permission/WEB_API.md b/docs/platform/topics/identity-permission/WEB_API.md index b4e785858..69a4b5a37 100644 --- a/docs/platform/topics/identity-permission/WEB_API.md +++ b/docs/platform/topics/identity-permission/WEB_API.md @@ -9,6 +9,7 @@ | 方法 | URL | 功能 | | ------ | ------------------- | -------------------------------------------------------------------------------------------------- | | `POST` | `/iam.auth/login` | 用户登录。请求包含 `tenantId`、`username`、`password`;返回 Bearer token、当前登录 `sessionId`、签发时间和当前用户信息。 | +| `GET` | `/iam.auth/login-context?tenantId={tenantId}` | 匿名读取 URL 锁定租户的公开登录上下文。该端点以平台 `ANONYMOUS_ALLOWED` 动作登记并经统一策略链路执行;仅返回锁定租户标识及工作台品牌投影,供标准登录页展示 Logo、主标题和副标题;目标租户必须处于启用状态。 | | `POST` | `/iam.auth/logout` | 当前 Bearer token 登出。token 从 `Authorization: Bearer ...` 读取。 | | `GET` | `/iam.auth/context` | 返回当前请求解析出的用户上下文,用于前端会话恢复和启动态确认。 | | `GET` | `/iam.auth/tenant-branding` | 返回当前租户的工作台品牌投影(`lightLogo`、可选的 `darkLogo`);Logo 以受限 Base64 图片 data URL 保存。 | diff --git a/muyun-iam-web/src/main/java/net/ximatai/muyun/spring/iam/web/LoginWebController.java b/muyun-iam-web/src/main/java/net/ximatai/muyun/spring/iam/web/LoginWebController.java index 3c02c05f7..398c7258f 100644 --- a/muyun-iam-web/src/main/java/net/ximatai/muyun/spring/iam/web/LoginWebController.java +++ b/muyun-iam-web/src/main/java/net/ximatai/muyun/spring/iam/web/LoginWebController.java @@ -13,6 +13,7 @@ import net.ximatai.muyun.spring.iam.user.UpdateCurrentUserProfileRequest; import net.ximatai.muyun.spring.iam.user.UserSessionService; import net.ximatai.muyun.spring.iam.tenant.TenantBranding; +import net.ximatai.muyun.spring.iam.tenant.TenantLoginContext; import net.ximatai.muyun.spring.iam.tenant.TenantService; import net.ximatai.muyun.spring.platform.web.PlatformStaticActionDeclaration; import net.ximatai.muyun.spring.common.platform.ActionAccessMode; @@ -22,6 +23,7 @@ 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; import org.springframework.web.bind.annotation.RestController; import org.springframework.beans.factory.annotation.Autowired; @@ -100,6 +102,18 @@ public TenantBranding tenantBranding() { : tenantService.branding(tenantId); } + /** + * Resolves branding for a tenant explicitly selected by the unauthenticated login entry. + */ + @GetMapping("/login-context") + @CustomActionEndpoint(value = "loginContext", title = "获取登录入口上下文", + accessMode = ActionAccessMode.ANONYMOUS_ALLOWED, actionAuth = false, dataAuth = false) + public TenantLoginContext loginContext(@RequestParam String tenantId) { + TenantService service = requireTenantService(); + service.requireActiveTenant(tenantId); + return new TenantLoginContext(tenantId, service.branding(tenantId)); + } + private String bearerToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header == null || header.isBlank()) { @@ -124,6 +138,13 @@ private CurrentUserProfileService requireCurrentUserProfileService() { return currentUserProfileService; } + private TenantService requireTenantService() { + if (tenantService == null) { + throw new IllegalStateException("tenant service is not available"); + } + return tenantService; + } + private String clientIp(HttpServletRequest request) { String forwardedFor = request.getHeader("X-Forwarded-For"); if (forwardedFor != null && !forwardedFor.isBlank()) { diff --git a/muyun-iam-web/src/test/java/net/ximatai/muyun/spring/iam/web/LoginWebControllerTest.java b/muyun-iam-web/src/test/java/net/ximatai/muyun/spring/iam/web/LoginWebControllerTest.java index 1cc71b384..fd810f4f2 100644 --- a/muyun-iam-web/src/test/java/net/ximatai/muyun/spring/iam/web/LoginWebControllerTest.java +++ b/muyun-iam-web/src/test/java/net/ximatai/muyun/spring/iam/web/LoginWebControllerTest.java @@ -6,12 +6,18 @@ import net.ximatai.muyun.spring.common.exception.PlatformErrorCodes; import net.ximatai.muyun.spring.common.identity.CurrentUser; import net.ximatai.muyun.spring.common.identity.CurrentUserContext; +import net.ximatai.muyun.spring.common.platform.ActionAuthorizationResult; +import net.ximatai.muyun.spring.common.platform.ActionExecutionContext; +import net.ximatai.muyun.spring.common.platform.ActionExecutionPolicyService; import net.ximatai.muyun.spring.common.tenant.TenantContext; import net.ximatai.muyun.spring.iam.user.UserSessionService; import net.ximatai.muyun.spring.iam.user.CurrentUserProfile; import net.ximatai.muyun.spring.iam.user.CurrentUserProfileService; import net.ximatai.muyun.spring.iam.tenant.TenantBranding; +import net.ximatai.muyun.spring.iam.tenant.Tenant; import net.ximatai.muyun.spring.iam.tenant.TenantService; +import net.ximatai.muyun.spring.platform.web.ActionEndpointContextResolver; +import net.ximatai.muyun.spring.platform.web.ActionEndpointInterceptor; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; @@ -20,6 +26,7 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.verify; @@ -92,6 +99,56 @@ void shouldExposeOnlyTheCurrentTenantBrandingToWorkbenchStartup() throws Excepti verify(tenantService).branding("tenant-a"); } + @Test + void shouldExposeActiveLockedTenantBrandingBeforeAuthentication() throws Exception { + TenantService tenantService = mock(TenantService.class); + when(tenantService.requireActiveTenant("tenant-a")).thenReturn(mock(Tenant.class)); + when(tenantService.branding("tenant-a")) + .thenReturn(new TenantBranding("data:image/png;base64,bGlnaHQ=", null, + "logoWithTitle", "租户 A", "租户专属工作台")); + LoginWebController controller = new LoginWebController(mock(UserSessionService.class), tenantService); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mvc.perform(get("/iam.auth/login-context").param("tenantId", "tenant-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tenantId").value("tenant-a")) + .andExpect(jsonPath("$.branding.lightLogo").value("data:image/png;base64,bGlnaHQ=")) + .andExpect(jsonPath("$.branding.title").value("租户 A")) + .andExpect(jsonPath("$.branding.subtitle").value("租户专属工作台")); + + verify(tenantService).requireActiveTenant("tenant-a"); + verify(tenantService).branding("tenant-a"); + } + + @Test + void shouldAuthorizePublicLoginContextThroughThePlatformActionChain() throws Exception { + TenantService tenantService = mock(TenantService.class); + when(tenantService.requireActiveTenant("tenant-a")).thenReturn(mock(Tenant.class)); + when(tenantService.branding("tenant-a")).thenReturn(TenantBranding.empty()); + ActionExecutionPolicyService policyService = mock(ActionExecutionPolicyService.class); + when(policyService.authorize(any(ActionExecutionContext.class))) + .thenAnswer(invocation -> ActionAuthorizationResult.allowed(invocation.getArgument(0))); + LoginWebController controller = new LoginWebController(mock(UserSessionService.class), tenantService); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .addInterceptors(new ActionEndpointInterceptor(policyService, new ActionEndpointContextResolver())) + .build(); + + mvc.perform(get("/iam.auth/login-context").param("tenantId", "tenant-a")) + .andExpect(status().isOk()); + + org.mockito.ArgumentCaptor context = + org.mockito.ArgumentCaptor.forClass(ActionExecutionContext.class); + verify(policyService).authorize(context.capture()); + org.assertj.core.api.Assertions.assertThat(context.getValue()).satisfies(value -> { + org.assertj.core.api.Assertions.assertThat(value.moduleAlias()).isEqualTo("iam.user"); + org.assertj.core.api.Assertions.assertThat(value.actionCode()).isEqualTo("loginContext"); + org.assertj.core.api.Assertions.assertThat(value.actionPolicy().accessMode().name()) + .isEqualTo("ANONYMOUS_ALLOWED"); + org.assertj.core.api.Assertions.assertThat(value.actionPolicy().actionAuth()).isFalse(); + org.assertj.core.api.Assertions.assertThat(value.actionPolicy().dataAuth()).isFalse(); + }); + } + @Test void shouldReturnUnauthorizedWhenLoginCredentialsAreInvalid() throws Exception { UserSessionService userSessionService = mock(UserSessionService.class); diff --git a/muyun-iam/src/main/java/net/ximatai/muyun/spring/iam/tenant/TenantLoginContext.java b/muyun-iam/src/main/java/net/ximatai/muyun/spring/iam/tenant/TenantLoginContext.java new file mode 100644 index 000000000..c4423f1dc --- /dev/null +++ b/muyun-iam/src/main/java/net/ximatai/muyun/spring/iam/tenant/TenantLoginContext.java @@ -0,0 +1,10 @@ +package net.ximatai.muyun.spring.iam.tenant; + +/** + * Public, unauthenticated login-entry facts for a tenant selected by the login URL. + * + *

This projection deliberately contains only the locked tenant identity and its + * decorative branding. It is not a tenant-management or current-session projection.

+ */ +public record TenantLoginContext(String tenantId, TenantBranding branding) { +} diff --git a/muyun-platform-web/src/test/java/net/ximatai/muyun/spring/platform/web/StaticModuleDefinitionScannerTest.java b/muyun-platform-web/src/test/java/net/ximatai/muyun/spring/platform/web/StaticModuleDefinitionScannerTest.java index e2d5be792..544ef8063 100644 --- a/muyun-platform-web/src/test/java/net/ximatai/muyun/spring/platform/web/StaticModuleDefinitionScannerTest.java +++ b/muyun-platform-web/src/test/java/net/ximatai/muyun/spring/platform/web/StaticModuleDefinitionScannerTest.java @@ -348,13 +348,20 @@ void shouldScanIamStaticModulesAndActionsFromControllerAnnotations() { .containsExactlyInAnyOrder("menu", "create", "view", "update", "delete", "query", "enable", "disable", "userSelector", "changePassword", "resetPassword", "forceLogout", "sessions", "sessionStatuses", "revokeSession", "revokeSessions", - "employeeBinding", "selfProfile"); + "employeeBinding", "selfProfile", "loginContext"); assertThat(definition.actions()).filteredOn(action -> action.actionCode().equals("selfProfile")) .singleElement() .satisfies(action -> { assertThat(action.accessMode()).isEqualTo(EntityActionAccessMode.LOGIN_REQUIRED); assertThat(action.actionAuth()).isFalse(); }); + assertThat(definition.actions()).filteredOn(action -> action.actionCode().equals("loginContext")) + .singleElement() + .satisfies(action -> { + assertThat(action.accessMode()).isEqualTo(EntityActionAccessMode.ANONYMOUS_ALLOWED); + assertThat(action.actionAuth()).isFalse(); + assertThat(action.dataAuth()).isFalse(); + }); assertThat(definition.actions()).filteredOn(action -> action.actionCode().equals("userSelector")) .singleElement() .satisfies(action -> { diff --git a/muyun-web/src/App.vue b/muyun-web/src/App.vue index 059e49375..643e9e8d0 100644 --- a/muyun-web/src/App.vue +++ b/muyun-web/src/App.vue @@ -22,6 +22,7 @@ import { configureModuleContext, createModuleContext, createAuthClient, + createLoginContextClient, provideModuleContextConfig, userPreferences, type AppError, @@ -166,7 +167,9 @@ provideCurrentUserContext(currentUser); providePlatformTimeZoneContext(currentTimeZone); provideWorkbenchNavigation({ openPage: handleOpenPage, replacePage: handleReplacePage }); -const authClient = createAuthClient(createBackendHttpClient({ withAuth: false })); +const anonymousHttpClient = createBackendHttpClient({ withAuth: false }); +const authClient = createAuthClient(anonymousHttpClient); +const loginContextClient = createLoginContextClient(anonymousHttpClient); configureAuthenticationRecovery((error, token) => { if (!isCurrentAuthToken(token)) { @@ -820,6 +823,7 @@ function requiresLogin(cause: unknown) { -import { ref } from 'vue'; -import type { AuthClient } from '@muyun/web-core'; -import type { LoginResult } from '@muyun/web-contracts'; +import { computed, onMounted, ref } from 'vue'; +import type { AuthClient, LoginContextClient } from '@muyun/web-core'; +import type { LoginResult, TenantBranding } from '@muyun/web-contracts'; import { UiButton, UiInput } from '@muyun/vue-ui-antdv'; import { normalizeInitialValue, resolveLoginTenantDefaults } from './loginTenant'; @@ -9,8 +9,11 @@ defineOptions({ name: 'LoginView' }); const props = defineProps<{ authClient: AuthClient; + loginContextClient?: LoginContextClient; loading?: boolean; error?: string; + /** Used only when the form is first created; URL-locked tenants always take precedence. */ + initialUsername?: string; }>(); const emit = defineEmits<{ @@ -20,7 +23,10 @@ const emit = defineEmits<{ const loginTenantDefaults = resolveLoginTenantDefaults(import.meta.env.VITE_MUYUN_LOGIN_TENANT_ID); const tenantId = ref(loginTenantDefaults.tenantId); const tenantLocked = loginTenantDefaults.tenantLocked; -const username = ref(normalizeInitialValue(import.meta.env.VITE_MUYUN_LOGIN_USERNAME)); +const username = ref( + normalizeInitialValue(props.initialUsername) || + normalizeInitialValue(import.meta.env.VITE_MUYUN_LOGIN_USERNAME), +); const password = ref(normalizeInitialValue(import.meta.env.VITE_MUYUN_LOGIN_PASSWORD)); const submitting = ref(false); const formError = ref(); @@ -28,6 +34,31 @@ const passwordChangeRequired = ref(false); const pendingToken = ref(); const newPassword = ref(''); const confirmPassword = ref(''); +const tenantBranding = ref(); +const loginContextLoading = ref(false); +const loginContextError = ref(); +const canSubmit = computed(() => !tenantLocked || (!loginContextLoading.value && !loginContextError.value)); +const showLoginTitleArea = computed(() => tenantBranding.value?.mode !== 'logoOnly'); +const loginTitle = computed(() => tenantBranding.value?.title || '平台登录'); +const loginSubtitle = computed(() => tenantBranding.value?.subtitle); + +onMounted(async () => { + if (!tenantLocked || !tenantId.value) { + return; + } + loginContextLoading.value = true; + try { + if (!props.loginContextClient) { + throw new Error('login context client is unavailable'); + } + const context = await props.loginContextClient.loginContext(tenantId.value); + tenantBranding.value = context.branding; + } catch { + loginContextError.value = '无法打开该租户的登录入口'; + } finally { + loginContextLoading.value = false; + } +}); async function submit() { formError.value = undefined; @@ -91,17 +122,22 @@ async function submitPasswordChange() {