feat(auth): 添加多租户登录支持和权限控制

- 在登录接口中添加租户编码参数支持
- 实现租户隔离的用户认证逻辑
- 添加平台管理员和租户用户的区分处理
- 集成 MyBatis Plus 多租户插件实现数据隔离
- 在 JWT Token 中添加租户 ID 信息
- 实现前端登录页面租户编码输入字段
- 添加 401 认证失败时的自动登出处理
- 优化权限缓存机制并集成 Redis
- 添加租户状态和过期时间验证
master
chenhao 2026-02-12 15:51:03 +08:00
parent 5b73b53de3
commit 69dc3e6788
13 changed files with 262 additions and 32 deletions

View File

@ -1,12 +1,19 @@
package com.imeeting.auth; package com.imeeting.auth;
import com.imeeting.entity.SysTenant;
import com.imeeting.entity.SysUser;
import com.imeeting.security.LoginUser; import com.imeeting.security.LoginUser;
import com.imeeting.service.SysParamService;
import com.imeeting.service.SysPermissionService; import com.imeeting.service.SysPermissionService;
import com.imeeting.mapper.SysTenantMapper;
import com.imeeting.mapper.SysUserMapper;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
@ -14,21 +21,42 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Set; import java.util.Set;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final SysPermissionService sysPermissionService; private final SysPermissionService sysPermissionService;
private final SysTenantMapper sysTenantMapper;
private final SysUserMapper sysUserMapper;
private final SysParamService sysParamService;
private final StringRedisTemplate redisTemplate;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, SysPermissionService sysPermissionService) { public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
@Lazy SysPermissionService sysPermissionService,
SysTenantMapper sysTenantMapper,
SysUserMapper sysUserMapper,
@Lazy SysParamService sysParamService,
StringRedisTemplate redisTemplate) {
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.sysPermissionService = sysPermissionService; this.sysPermissionService = sysPermissionService;
this.sysTenantMapper = sysTenantMapper;
this.sysUserMapper = sysUserMapper;
this.sysParamService = sysParamService;
this.redisTemplate = redisTemplate;
} }
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
String uri = request.getRequestURI();
// Skip filter for public endpoints
if (uri.startsWith("/auth/") || uri.equals("/api/params/value")) {
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) { if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); String token = authHeader.substring(7);
@ -36,10 +64,51 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
Claims claims = jwtTokenProvider.parseToken(token); Claims claims = jwtTokenProvider.parseToken(token);
String username = claims.get("username", String.class); String username = claims.get("username", String.class);
Long userId = claims.get("userId", Long.class); Long userId = claims.get("userId", Long.class);
Long tenantId = claims.get("tenantId", Long.class);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
Set<String> permissions = sysPermissionService.listPermissionCodesByUserId(userId); // 1. Validate User Status (Ignore Tenant isolation here)
LoginUser loginUser = new LoginUser(userId, username, permissions); SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId);
if (user == null || user.getStatus() != 1 || user.getIsDeleted() == 1) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User account is disabled or deleted");
return;
}
// 2. Validate Tenant Status & Grace Period
if (tenantId != null) {
SysTenant tenant = sysTenantMapper.selectByIdIgnoreTenant(tenantId);
if (tenant == null || tenant.getStatus() != 1) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Tenant is disabled");
return;
}
if (tenant.getExpireTime() != null) {
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(tenant.getExpireTime())) {
String graceDaysStr = sysParamService.getParamValue("sys.tenant.grace_period_days", "0");
int graceDays = Integer.parseInt(graceDaysStr);
if (now.isAfter(tenant.getExpireTime().plusDays(graceDays))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Tenant subscription expired");
return;
}
}
}
}
// 3. Get Permissions (With Redis Cache)
String permKey = "sys:auth:perm:" + userId;
Set<String> permissions;
String cachedPerms = redisTemplate.opsForValue().get(permKey);
if (cachedPerms != null) {
permissions = Set.of(cachedPerms.split(","));
} else {
permissions = sysPermissionService.listPermissionCodesByUserId(userId);
if (permissions != null && !permissions.isEmpty()) {
redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2));
}
}
LoginUser loginUser = new LoginUser(userId, tenantId, username, user.getIsPlatformAdmin(), permissions);
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());

View File

@ -5,6 +5,7 @@ import lombok.Data;
@Data @Data
public class LoginRequest { public class LoginRequest {
private String tenantCode;
@NotBlank @NotBlank
private String username; private String username;
@NotBlank @NotBlank

View File

@ -1,14 +1,64 @@
package com.imeeting.config; package com.imeeting.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.imeeting.security.LoginUser;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Configuration @Configuration
public class MybatisPlusConfig { public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
LoginUser user = (LoginUser) auth.getPrincipal();
if (user.getTenantId() != null) {
return new LongValue(user.getTenantId());
}
}
// If no tenant context (e.g. system task or error), return 0
return new LongValue(0);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// System level tables that should not be filtered by tenant_id
// and check if current user is platform admin to skip filtering
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
LoginUser user = (LoginUser) auth.getPrincipal();
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
return true;
}
}
return List.of("sys_tenant", "sys_dict_type", "sys_dict_item", "sys_log").contains(tableName.toLowerCase());
}
}));
return interceptor;
}
@Bean @Bean
public MetaObjectHandler metaObjectHandler() { public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() { return new MetaObjectHandler() {

View File

@ -3,7 +3,13 @@ package com.imeeting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.SysTenant; import com.imeeting.entity.SysTenant;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Param;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
@Mapper @Mapper
public interface SysTenantMapper extends BaseMapper<SysTenant> { public interface SysTenantMapper extends BaseMapper<SysTenant> {
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_tenant WHERE id = #{id}")
SysTenant selectByIdIgnoreTenant(@Param("id") Long id);
} }

View File

@ -5,10 +5,30 @@ import com.imeeting.entity.SysUser;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import java.util.List; import java.util.List;
@Mapper @Mapper
public interface SysUserMapper extends BaseMapper<SysUser> { public interface SysUserMapper extends BaseMapper<SysUser> {
@Select("SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.user_id = ur.user_id WHERE ur.role_id = #{roleId}") @Select("SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.user_id = ur.user_id WHERE ur.role_id = #{roleId}")
List<SysUser> selectUsersByRoleId(@Param("roleId") Long roleId); List<SysUser> selectUsersByRoleId(@Param("roleId") Long roleId);
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_user WHERE username = #{username} AND is_deleted = 0")
SysUser selectByUsernameIgnoreTenant(@Param("username") String username);
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_user WHERE user_id = #{userId} AND is_deleted = 0")
SysUser selectByIdIgnoreTenant(@Param("userId") Long userId);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT u.*
FROM sys_user u
JOIN sys_tenant t ON u.tenant_id = t.id
WHERE u.username = #{username}
AND t.tenant_code = #{tenantCode}
AND u.is_deleted = 0
""")
SysUser selectByUsernameAndTenantCode(@Param("username") String username, @Param("tenantCode") String tenantCode);
} }

View File

@ -16,7 +16,9 @@ import java.util.stream.Collectors;
@AllArgsConstructor @AllArgsConstructor
public class LoginUser implements UserDetails { public class LoginUser implements UserDetails {
private Long userId; private Long userId;
private Long tenantId;
private String username; private String username;
private Boolean isPlatformAdmin;
private Set<String> permissions; private Set<String> permissions;
@Override @Override

View File

@ -9,6 +9,7 @@ import com.imeeting.common.SysParamKeys;
import com.imeeting.entity.Device; import com.imeeting.entity.Device;
import com.imeeting.entity.SysLog; import com.imeeting.entity.SysLog;
import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUser;
import com.imeeting.mapper.SysUserMapper;
import com.imeeting.service.*; import com.imeeting.service.*;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -26,6 +27,7 @@ import java.util.UUID;
@Service @Service
public class AuthServiceImpl implements AuthService { public class AuthServiceImpl implements AuthService {
private final SysUserService sysUserService; private final SysUserService sysUserService;
private final SysUserMapper sysUserMapper;
private final DeviceService deviceService; private final DeviceService deviceService;
private final SysParamService sysParamService; private final SysParamService sysParamService;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
@ -41,10 +43,17 @@ public class AuthServiceImpl implements AuthService {
@Value("${app.captcha.max-attempts:5}") @Value("${app.captcha.max-attempts:5}")
private int captchaMaxAttempts; private int captchaMaxAttempts;
public AuthServiceImpl(SysUserService sysUserService, DeviceService deviceService, SysParamService sysParamService, public AuthServiceImpl(SysUserService sysUserService,
StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder, SysUserMapper sysUserMapper,
JwtTokenProvider jwtTokenProvider, SysLogService sysLogService, HttpServletRequest httpServletRequest) { DeviceService deviceService,
SysParamService sysParamService,
StringRedisTemplate stringRedisTemplate,
PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider,
SysLogService sysLogService,
HttpServletRequest httpServletRequest) {
this.sysUserService = sysUserService; this.sysUserService = sysUserService;
this.sysUserMapper = sysUserMapper;
this.deviceService = deviceService; this.deviceService = deviceService;
this.sysParamService = sysParamService; this.sysParamService = sysParamService;
this.stringRedisTemplate = stringRedisTemplate; this.stringRedisTemplate = stringRedisTemplate;
@ -61,12 +70,20 @@ public class AuthServiceImpl implements AuthService {
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
} }
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>() SysUser user;
.eq(SysUser::getUsername, request.getUsername()) if (request.getTenantCode() == null || request.getTenantCode().trim().isEmpty()) {
.eq(SysUser::getIsDeleted, 0) // 平台管理登录逻辑 (全局搜索)
.eq(SysUser::getStatus, 1)); user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername());
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { if (user != null && !Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
throw new IllegalArgumentException("用户名或密码错误"); throw new IllegalArgumentException("非平台管理账号请提供租户编码");
}
} else {
// 租户用户登录逻辑 (按租户搜索)
user = sysUserMapper.selectByUsernameAndTenantCode(request.getUsername(), request.getTenantCode().trim());
}
if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
throw new IllegalArgumentException("用户名、密码或租户编码错误");
} }
String deviceCode = request.getDeviceCode(); String deviceCode = request.getDeviceCode();
@ -150,12 +167,15 @@ public class AuthServiceImpl implements AuthService {
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
} }
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>() SysUser user;
.eq(SysUser::getUsername, request.getUsername()) if (request.getTenantCode() == null || request.getTenantCode().trim().isEmpty()) {
.eq(SysUser::getIsDeleted, 0) user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername());
.eq(SysUser::getStatus, 1)); } else {
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { user = sysUserMapper.selectByUsernameAndTenantCode(request.getUsername(), request.getTenantCode().trim());
throw new IllegalArgumentException("用户名或密码错误"); }
if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
throw new IllegalArgumentException("用户名、密码或租户编码错误");
} }
String deviceCode = UUID.randomUUID().toString().replace("-", ""); String deviceCode = UUID.randomUUID().toString().replace("-", "");
@ -209,12 +229,14 @@ public class AuthServiceImpl implements AuthService {
Map<String, Object> accessClaims = new HashMap<>(); Map<String, Object> accessClaims = new HashMap<>();
accessClaims.put("tokenType", "access"); accessClaims.put("tokenType", "access");
accessClaims.put("userId", user.getUserId()); accessClaims.put("userId", user.getUserId());
accessClaims.put("tenantId", user.getTenantId());
accessClaims.put("username", user.getUsername()); accessClaims.put("username", user.getUsername());
accessClaims.put("deviceCode", deviceCode); accessClaims.put("deviceCode", deviceCode);
Map<String, Object> refreshClaims = new HashMap<>(); Map<String, Object> refreshClaims = new HashMap<>();
refreshClaims.put("tokenType", "refresh"); refreshClaims.put("tokenType", "refresh");
refreshClaims.put("userId", user.getUserId()); refreshClaims.put("userId", user.getUserId());
refreshClaims.put("tenantId", user.getTenantId());
refreshClaims.put("deviceCode", deviceCode); refreshClaims.put("deviceCode", deviceCode);
String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis()); String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());

View File

@ -61,6 +61,7 @@ public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> i
} else { } else {
// 4. 数据库也无数据,设置空标记防止穿透 // 4. 数据库也无数据,设置空标记防止穿透
try { try {
// Use default value or empty marker if needed
redisTemplate.opsForValue().set(redisKey, RedisKeys.CACHE_EMPTY_MARKER, Duration.ofMinutes(5)); redisTemplate.opsForValue().set(redisKey, RedisKeys.CACHE_EMPTY_MARKER, Duration.ofMinutes(5));
} catch (Exception e) { } catch (Exception e) {
log.error("Redis write empty marker error for key {}: {}", redisKey, e.getMessage()); log.error("Redis write empty marker error for key {}: {}", redisKey, e.getMessage());

View File

@ -2,8 +2,11 @@ package com.imeeting.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.entity.SysPermission; import com.imeeting.entity.SysPermission;
import com.imeeting.entity.SysUser;
import com.imeeting.mapper.SysPermissionMapper; import com.imeeting.mapper.SysPermissionMapper;
import com.imeeting.service.SysPermissionService; import com.imeeting.service.SysPermissionService;
import com.imeeting.service.SysUserService;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@ -12,14 +15,29 @@ import java.util.stream.Collectors;
@Service @Service
public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, SysPermission> implements SysPermissionService { public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, SysPermission> implements SysPermissionService {
private final SysUserService sysUserService;
public SysPermissionServiceImpl(@Lazy SysUserService sysUserService) {
this.sysUserService = sysUserService;
}
@Override @Override
public List<SysPermission> listByUserId(Long userId) { public List<SysPermission> listByUserId(Long userId) {
if (userId == null) { if (userId == null) {
return List.of(); return List.of();
} }
// Super admin or Platform Admin gets all permissions
if (userId == 1L) { if (userId == 1L) {
return list(); return list();
} }
SysUser user = sysUserService.getById(userId);
if (user != null && Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
return list();
}
return baseMapper.selectByUserId(userId); return baseMapper.selectByUserId(userId);
} }

View File

@ -1,31 +1,48 @@
package com.imeeting.service.impl; package com.imeeting.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUser;
import com.imeeting.mapper.SysUserMapper; import com.imeeting.mapper.SysUserMapper;
import com.imeeting.service.SysUserService; import com.imeeting.service.SysUserService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@Service @Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService { public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
@Override @Override
public List<SysUser> listUsersByRoleId(Long roleId) { public List<SysUser> listUsersByRoleId(Long roleId) {
return baseMapper.selectUsersByRoleId(roleId); return baseMapper.selectUsersByRoleId(roleId);
} }
@Override
public boolean save(SysUser entity) {
validateUniqueUsername(entity);
return super.save(entity);
} }
@Override
public boolean updateById(SysUser entity) {
validateUniqueUsername(entity);
return super.updateById(entity);
}
private void validateUniqueUsername(SysUser user) {
if (user.getUsername() == null) return;
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, user.getUsername())
.eq(SysUser::getTenantId, user.getTenantId())
.eq(SysUser::getIsDeleted, 0);
if (user.getUserId() != null) {
query.ne(SysUser::getUserId, user.getUserId());
}
if (count(query) > 0) {
throw new IllegalArgumentException("该租户下已存在名为 [" + user.getUsername() + "] 的用户");
}
}
}

View File

@ -15,6 +15,7 @@ export interface TokenResponse {
export interface LoginPayload { export interface LoginPayload {
username: string; username: string;
password: string; password: string;
tenantCode?: string;
captchaId?: string; captchaId?: string;
captchaCode?: string; captchaCode?: string;
deviceCode?: string; deviceCode?: string;

View File

@ -22,7 +22,17 @@ http.interceptors.response.use(
} }
return resp; return resp;
}, },
(error) => Promise.reject(error) (error) => {
if (error.response && error.response.status === 401) {
// Clear session/local storage
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
sessionStorage.removeItem("userProfile");
// Force redirect to login
window.location.href = "/login";
}
return Promise.reject(error);
}
); );
export default http; export default http;

View File

@ -2,7 +2,7 @@ import { Button, Checkbox, Form, Input, message, Typography } from "antd";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth"; import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
import { getCurrentUser, getSystemParamValue } from "../api"; import { getCurrentUser, getSystemParamValue } from "../api";
import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined } from "@ant-design/icons"; import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
import "./Login.css"; import "./Login.css";
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
@ -48,6 +48,7 @@ export default function Login() {
const data = await login({ const data = await login({
username: values.username, username: values.username,
password: values.password, password: values.password,
tenantCode: values.tenantCode,
captchaId: captchaEnabled ? captcha?.captchaId : undefined, captchaId: captchaEnabled ? captcha?.captchaId : undefined,
captchaCode: captchaEnabled ? values.captchaCode : undefined captchaCode: captchaEnabled ? values.captchaCode : undefined
}); });
@ -114,6 +115,18 @@ export default function Login() {
requiredMark={false} requiredMark={false}
autoComplete="off" autoComplete="off"
> >
<Form.Item
name="tenantCode"
rules={[{ required: false }]}
>
<Input
size="large"
prefix={<ShopOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="租户编码 (平台管理可留空)"
aria-label="租户编码"
/>
</Form.Item>
<Form.Item <Form.Item
name="username" name="username"
rules={[{ required: true, message: "请输入用户名" }]} rules={[{ required: true, message: "请输入用户名" }]}