安全与认证
安全与认证
业务用途
平台采用 JWT 无状态认证:用户通过 POST /api/auth/login 校验账号密码(含验证码、登录失败锁定、多租户选择)后签发 JWT;后续请求在 Authorization: Bearer <token> 头中携带 token,由 JwtAuthenticationFilter 逐请求校验签名与有效期,并把用户身份写入 SecurityContextHolder。SecurityConfig 定义白名单(登录、验证码、SSO、开放接口、移动端、静态资源)与 CORS 策略,其余接口需认证。密码使用 BCryptPasswordEncoder 加盐存储。
涉及文件
| 文件 | 作用 |
|---|---|
backend/src/main/java/com/lowcode/controller/AuthController.java | 登录/登出/当前用户/验证码/权限/SSO 登录 |
backend/src/main/java/com/lowcode/security/JwtUtil.java | JWT 生成、解析、校验 |
backend/src/main/java/com/lowcode/security/JwtAuthenticationFilter.java | 每请求校验 Bearer token |
backend/src/main/java/com/lowcode/config/SecurityConfig.java | Spring Security 过滤链、白名单、CORS、密码编码器 |
backend/src/main/java/com/lowcode/security/UserDetailsServiceImpl.java | 按「租户 + 用户名」加载用户与权限 |
backend/src/main/java/com/lowcode/config/TenantContext.java | 租户 ThreadLocal(过滤器写入) |
数据库表
登录与鉴权涉及以下系统表(由 DataInitializer 启动时播种,详见 ):
| 表 | 关键字段 | 说明 |
|---|---|---|
sys_user | username、password(BCrypt)、status(0 正常 / 1 禁用)、tenant_id | 用户账号;username 在租户内唯一(uk_tenant_username) |
sys_tenant | tenant_code、status(1 启用)、expire_time | 租户;停用/过期则拒绝登录 |
sys_role | role_code、tenant_id | 角色(如 admin、user) |
sys_user_role | user_id、role_id、tenant_id | 用户-角色关联 |
sys_menu | permission、tenant_id | 菜单与权限标识(如 system:user:list) |
后端实现
AuthController 端点一览
AuthController 映射在 @RequestMapping("/api/auth"),提供以下端点:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login | 登录,校验账号密码/验证码,签发 JWT |
| GET | /api/auth/userInfo | 获取当前登录用户信息(注意是 userInfo,非 info) |
| POST | /api/auth/logout | 退出登录(记录审计日志) |
| GET | /api/auth/captcha | 生成图形验证码(Base64 图片 + uuid) |
| GET | /api/auth/permissions | 当前用户权限标识列表 |
| GET | /api/auth/roles | 当前用户角色编码列表 |
| POST | /api/auth/sso/login | SSO 外部 token 登录(OIDC 风格) |
登录流程 login
// backend/src/main/java/com/lowcode/controller/AuthController.java
@PostMapping("/login")
public Result<Map<String, Object>> login(@RequestBody LoginRequest request, HttpServletRequest httpRequest) {
TenantContext.clear(); // 登录前先清空租户上下文
String clientIp = getClientIp(httpRequest);
// 1. 登录失败锁定:同 IP 失败 5 次,锁定 15 分钟
long[] failInfo = LOGIN_FAIL_COUNT.get(clientIp);
if (failInfo != null && failCount >= MAX_LOGIN_FAILS && 未过锁定期) {
return Result.error(429, "登录过于频繁,请15分钟后再试");
}
// 2. 验证码校验(仅当请求带了 captcha + uuid 时)
if (request.getCaptcha() != null && request.getUuid() != null) {
String cachedCode = CAPTCHA_CACHE.remove(request.getUuid());
if (cachedCode == null || !cachedCode.equalsIgnoreCase(request.getCaptcha())) {
return Result.error(400, "验证码错误");
}
}
// 3. 按用户名查用户,可选按 tenantId 过滤
List<SysUser> matchedUsers = userMapper.selectList(... eq(SysUser::getUsername, request.getUsername()));
// 4. BCrypt 比对密码
List<SysUser> passwordMatchedUsers = matchedUsers.stream()
.filter(item -> passwordEncoder.matches(request.getPassword(), item.getPassword())).toList();
if (passwordMatchedUsers.isEmpty()) {
recordLoginFail(clientIp);
return Result.error(401, "用户名或密码错误");
}
// 5. 用户启用状态(status != "1") + 租户启用/过期校验
// 6. 若未指定 tenantId 且用户属于多个启用租户 -> 返回 requireTenantSelect 让前端选择
// 7. 登录成功:清失败计数,签发 token
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getTenantId());
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("userInfo", buildUserInfo(user)); // id/username/nickname/avatar/email/phone/tenantId
result.put("permissions", getUserPermissions(user));
result.put("roles", getUserRoles(user.getId()));
return Result.success("登录成功", result);
}LoginRequest 是 AuthController 的内部静态类,字段:username、password、tenantId、captcha、uuid。
验证码是可选的
只有当请求同时带了 captcha 和 uuid 时才校验验证码;前端若不传这两个字段,登录直接走账号密码。验证码通过 GET /api/auth/captcha 获取,返回 uuid + Base64 图片,存于内存 ConcurrentHashMap,校验后即移除(一次性)。
JwtUtil:创建/校验/解析
// backend/src/main/java/com/lowcode/security/JwtUtil.java
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret; // application.yml: lowcode-platform-secret-key-2024-...
@Value("${jwt.expiration}")
private Long expiration; // 86400000ms = 24 小时
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Long userId, String username, Long tenantId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.subject(String.valueOf(userId)) // subject = userId
.claim("username", username)
.claim("tenantId", tenantId)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSecretKey()) // HMAC-SHA 签名
.compact();
}
public Long getUserIdFromToken(String token) { return Long.parseLong(parseToken(token).getSubject()); }
public String getUsernameFromToken(String token) { return parseToken(token).get("username", String.class); }
public Long getTenantIdFromToken(String token) {
Number tenantId = parseToken(token).get("tenantId", Number.class);
return tenantId == null ? 1L : tenantId.longValue(); // 缺省租户 1
}
public boolean validateToken(String token) {
try { parseToken(token); return true; }
catch (JwtException | IllegalArgumentException e) { return false; }
}
public String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}- 使用 jjwt(
io.jsonwebtoken)库,HMAC-SHA 对称签名。 expiration配置项jwt.expiration: 86400000即 24 小时。- token payload 含三个关键 claim:
sub(userId)、username、tenantId。tenantId缺省回退为1L(默认租户)。 getTenantIdFromRequest另读Tenant-Id请求头(供个别场景直传租户)。
JwtAuthenticationFilter:每请求校验
// backend/src/main/java/com/lowcode/security/JwtAuthenticationFilter.java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = jwtUtil.getTokenFromRequest(request);
if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
TenantContext.setTenantId(jwtUtil.getTenantIdFromToken(token)); // 顺带写租户上下文
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.warn("JWT authentication failed: " + e.getMessage()); // 校验失败不中断,交由后续权限决策
}
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear(); // 请求结束清理 ThreadLocal,防止线程复用串号
}
}
}过滤器不主动拒绝请求
该过滤器只在 token 合法时填充 SecurityContextHolder;token 缺失或非法时仅 warn 后继续放行。真正的「拒绝」由 SecurityConfig 的 anyRequest().authenticated() 决定。白名单内接口无 token 也能通过;非白名单接口无合法 token 则被 Spring Security 拦下。
SecurityConfig:白名单与 CORS
// backend/src/main/java/com/lowcode/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtAuthFilter = new JwtAuthenticationFilter();
jwtAuthFilter.setJwtUtil(jwtUtil);
jwtAuthFilter.setUserDetailsService(userDetailsService);
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
.requestMatchers(
"/api/auth/login", "/api/auth/captcha", "/api/auth/register",
"/api/auth/sso/providers", "/api/auth/sso/authorize/**",
"/api/auth/sso/callback/**", "/api/auth/sso/token",
"/api/open/**", "/api/mobile/**",
"/uploads/**", "/upload/**"
).permitAll()
.anyRequest().authenticated() // 其余接口需认证
)
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 密码加盐哈希
}
}- 会话:
STATELESS,不创建 HttpSession,完全靠 JWT。 - 白名单是细粒度的:并非整段
/api/auth/**放行,而是只放行login/captcha/register/sso/*等具体路径,外加/api/open/**(开放平台)、/api/mobile/**(移动端)、/uploads/**与/upload/**(静态资源)。 - 非白名单接口强制认证:
anyRequest().authenticated(),因此业务接口必须携带有效 JWT,否则被拒绝。
与早期文档的差异
中提到「当前对业务接口采用 permitAll(JWT 校验存在但未强制拦截)」。但现行 SecurityConfig 源码已配置 anyRequest().authenticated(),业务接口已强制要求认证。以本文源码描述为准。
CORS 配置(同时存在于 SecurityConfig 与 WebMvcConfig,两处一致):
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setExposedHeaders(Arrays.asList("Authorization"));
configuration.setMaxAge(3600L);
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}UserDetailsServiceImpl:加载用户与权限
// backend/src/main/java/com/lowcode/security/UserDetailsServiceImpl.java
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Long tenantId = TenantContext.getTenantId() == null ? 1L : TenantContext.getTenantId();
SysUser user = userMapper.selectOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getUsername, username)
.last("LIMIT 1"));
if (user == null) throw new UsernameNotFoundException("用户不存在: " + username);
if ("1".equals(user.getStatus())) throw new UsernameNotFoundException("用户已被禁用: " + username);
// 取该用户在当前租户下的角色 -> 再取这些角色的菜单权限标识
List<Long> roleIds = userRoleMapper.selectList(...).stream().map(SysUserRole::getRoleId)...;
List<SimpleGrantedAuthority> authorities = roleIds.isEmpty() ? List.of()
: menuMapper.selectPermsByRoleIdsAndTenantId(roleIds, tenantId).stream()
.filter(...).distinct().map(SimpleGrantedAuthority::new).toList();
return new User(user.getUsername(), user.getPassword(), authorities);
}
}- 按 租户 + 用户名 查用户(
tenant_id+username),不同租户可存在同名用户。 tenantId取自TenantContext(由TenantFilter/JwtAuthenticationFilter从 JWT 写入),为空时回退默认租户1L。- 权限来源于
sys_user_role -> sys_role -> sys_menu(permission),通过SysMenuMapper.selectPermsByRoleIdsAndTenantId联查。 - 密码用
BCryptPasswordEncoder校验:passwordEncoder.matches(rawPassword, user.getPassword())。
操作步骤
- 获取验证码(可选):
GET /api/auth/captcha-> 拿到uuid与 Base64 图片,在登录表单让用户输入。 - 登录:
POST /api/auth/login,请求体示例:
{
"username": "admin",
"password": "admin123",
"tenantId": "1",
"captcha": "ABCD",
"uuid": "上一步拿到的 uuid"
}- 登录成功响应:
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"userInfo": {
"id": 1, "username": "admin", "nickname": "系统管理员",
"avatar": null, "email": "admin@lowcode.com",
"phone": "13800138000", "tenantId": 1
},
"permissions": ["system:user:list", "system:role:list", "..."],
"roles": ["admin"]
}
}- 多租户选择响应(未传
tenantId且用户属于多个启用租户时):
{
"code": 200,
"message": "请选择要进入的租户",
"data": {
"requireTenantSelect": true,
"tenants": [
{ "tenantId": "1", "tenantName": "默认租户", "tenantCode": "default" }
]
}
}- 携带 token 访问业务接口:后续所有请求加请求头
Authorization: Bearer <token>。 - 获取当前用户信息:
GET /api/auth/userInfo,返回userInfo对象。 - 退出:
POST /api/auth/logout(记录退出审计日志,前端清除本地 token)。
默认账号
DataInitializer 播种的默认管理员:账号 admin / 密码 admin123(租户 1「默认租户」)。新建租户的管理员密码为 123456。密码均以 BCrypt 存储。
常见问题
接口返回 401/403 怎么排查?
先确认请求头带了 Authorization: Bearer <token>;再用 jwt.expiration: 86400000(24h) 判断是否过期。若 token 合法仍 403,检查 SecurityConfig 白名单是否覆盖该路径,或接口是否加了 @PreAuthorize 且当前用户权限不足(@EnableMethodSecurity 已开启方法级注解)。
登录提示「登录过于频繁,请15分钟后再试」
同 IP 连续失败 5 次(MAX_LOGIN_FAILS)会触发 15 分钟锁定(LOCKOUT_DURATION_MS),计数存于内存 ConcurrentHashMap。等待 15 分钟自动解除,或重启后端清空。
多租户登录为什么先返回「请选择租户」?
同一用户名在多个启用租户下都存在且未在请求里指定 tenantId 时,后端无法确定进入哪个租户,故返回 requireTenantSelect=true 与候选租户列表。前端应让用户选择后,带 tenantId 重新调用 /api/auth/login。
为什么 /api/auth/info 404?
端点实际是 GET /api/auth/userInfo(驼峰),不是 /api/auth/info。前端调用时注意路径拼写。
createBy/updateBy 总是 "1"?
MyMetaObjectHandler 当前把操作人硬编码为 "1",详见 。审计日志(AuditLogAspect)则会从 JWT 解析真实 userId/username,二者来源不同。
