Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/platform/topics/identity-permission/WEB_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 保存。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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()) {
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<ActionExecutionContext> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.ximatai.muyun.spring.iam.tenant;

/**
* Public, unauthenticated login-entry facts for a tenant selected by the login URL.
*
* <p>This projection deliberately contains only the locked tenant identity and its
* decorative branding. It is not a tenant-management or current-session projection.</p>
*/
public record TenantLoginContext(String tenantId, TenantBranding branding) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
6 changes: 5 additions & 1 deletion muyun-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
configureModuleContext,
createModuleContext,
createAuthClient,
createLoginContextClient,
provideModuleContextConfig,
userPreferences,
type AppError,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -820,6 +823,7 @@ function requiresLogin(cause: unknown) {
<LoginView
v-if="loginRequired"
:auth-client="authClient"
:login-context-client="loginContextClient"
:loading="loginLoading"
:error="error"
@authenticated="handleAuthenticated"
Expand Down
86 changes: 74 additions & 12 deletions muyun-web/src/app/LoginView.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
<script setup lang="ts">
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';

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<{
Expand All @@ -20,14 +23,42 @@ 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<string>();
const passwordChangeRequired = ref(false);
const pendingToken = ref<string>();
const newPassword = ref('');
const confirmPassword = ref('');
const tenantBranding = ref<TenantBranding>();
const loginContextLoading = ref(false);
const loginContextError = ref<string>();
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;
Expand Down Expand Up @@ -91,17 +122,22 @@ async function submitPasswordChange() {
<main class="login-page">
<section class="login-panel">
<header>
<p>MuYun Platform</p>
<h1>平台登录</h1>
<div class="login-brand">
<img v-if="tenantBranding?.lightLogo" class="login-logo" :src="tenantBranding.lightLogo" alt="" />
<div v-if="showLoginTitleArea">
<p v-if="!tenantBranding?.lightLogo">MuYun Platform</p>
<h1>{{ loginTitle }}</h1>
<p v-if="loginSubtitle" class="login-subtitle">{{ loginSubtitle }}</p>
</div>
</div>
</header>

<p v-if="formError || error" class="login-error">
{{ formError || error }}
<p v-if="formError || error || loginContextError" class="login-error">
{{ formError || error || loginContextError }}
</p>

<form v-if="!passwordChangeRequired" class="login-form" @submit.prevent="submit">
<p v-if="tenantLocked" class="login-context">租户:{{ tenantId }}</p>
<label v-else>
<label v-if="!tenantLocked">
<span>租户 ID</span>
<UiInput v-model:value="tenantId" autocomplete="organization" placeholder="留空进入系统工作区" />
</label>
Expand All @@ -113,7 +149,13 @@ async function submitPasswordChange() {
<span>密码</span>
<UiInput v-model:value="password" type="password" autocomplete="current-password" required />
</label>
<UiButton class="login-submit" html-type="submit" type="primary" :loading="submitting || loading">
<UiButton
class="login-submit"
html-type="submit"
type="primary"
:disabled="!canSubmit"
:loading="submitting || loading || loginContextLoading"
>
{{ submitting || loading ? '登录中' : '登录' }}
</UiButton>
</form>
Expand Down Expand Up @@ -167,10 +209,30 @@ header p {
header h1 {
margin: 0;
color: var(--muyun-support-text);
font-size: 22px;
font-size: 26px;
line-height: 1.25;
}

.login-logo {
display: block;
width: 56px;
height: 56px;
object-fit: contain;
}

.login-brand {
display: flex;
align-items: flex-start;
gap: 14px;
}

.login-subtitle {
margin: 5px 0 0;
color: var(--muyun-support-text-muted);
font-size: 14px;
line-height: 1.5;
}

.login-error {
margin: 0;
padding: 10px 12px;
Expand Down
1 change: 1 addition & 0 deletions muyun-web/src/consumer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export {
} from '../vue-ui-antdv/index';
export { default as PlatformAdminOutlet } from './PlatformAdminOutlet.vue';
export { default as AppWorkbenchShell } from './AppWorkbenchShell.vue';
export { default as LoginView } from '../app/LoginView.vue';
export type { AppWorkbenchNavigation } from './workbenchNavigation';
export { configureUserPreferencePersistence } from './userPreferencePersistence';
export { default as ChangeOwnPasswordDialog } from '../app/ChangeOwnPasswordDialog.vue';
Expand Down
6 changes: 6 additions & 0 deletions muyun-web/src/web-contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ export interface TenantBranding {
subtitle?: string;
}

/** Public branding facts for a tenant locked by the unauthenticated login URL. */
export interface TenantLoginContext {
tenantId: string;
branding?: TenantBranding;
}

export interface LoginRequest {
tenantId?: string;
username: string;
Expand Down
13 changes: 13 additions & 0 deletions muyun-web/src/web-core/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
MenuTreeNode,
PageBootstrap,
TenantBranding,
TenantLoginContext,
} from '@muyun/web-contracts';
import type { HttpClient } from './http';

Expand All @@ -35,13 +36,25 @@ export interface AuthClient {
logout(token?: string): Promise<void>;
}

/** Resolves the public context used by a tenant-locked login entry. */
export interface LoginContextClient {
loginContext(tenantId: string): Promise<TenantLoginContext>;
}

export function createSessionClient(http: HttpClient): SessionClient {
return {
current: () => http.request<CurrentUser>({ path: '/iam.auth/context' }),
tenantBranding: () => http.request<TenantBranding>({ path: '/iam.auth/tenant-branding' }),
};
}

export function createLoginContextClient(http: HttpClient): LoginContextClient {
return {
loginContext: (tenantId) =>
http.request<TenantLoginContext>({ path: '/iam.auth/login-context', query: { tenantId } }),
};
}

export function createMenuClient(http: HttpClient): MenuClient {
return {
mine: async () => normalizeMenuMineResponse(await http.request<unknown>({ path: '/platform.menu/mine' })),
Expand Down
Loading
Loading