diff --git a/README.md b/README.md index 1e6c522..f7af8aa 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ Data Agent 反其道而行——**不引入任何向量检索**。语义层把 **前置依赖**:JDK 17+、Maven 3.9+、Node 18+、pnpm 8+、MySQL 8+、Python 3+(需安装 pandas、numpy、scipy)。 ```bash -# 1. 建元数据库并初始化表结构 +# 1. 建元数据库并初始化表结构(sql/ 目录下所有 .sql 文件均需执行) mysql -u root -p -e "CREATE DATABASE data_agent CHARACTER SET utf8mb4;" -mysql -u root -p data_agent < sql/data_source.sql +for f in sql/*.sql; do mysql -u root -p data_agent < "$f"; done # 2. 启动后端(默认端口 8080) cd data-agent-backend diff --git a/data-agent-backend/pom.xml b/data-agent-backend/pom.xml index 6936bf8..e5a1e53 100644 --- a/data-agent-backend/pom.xml +++ b/data-agent-backend/pom.xml @@ -144,6 +144,25 @@ jsqlparser 5.0 + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java b/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java index 399f5e2..730a7bc 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java @@ -33,6 +33,9 @@ public enum ErrorCode { /** 请求参数格式或取值非法,但没有更细分的业务错误码。 */ BAD_REQUEST("BAD_REQUEST", HttpStatus.BAD_REQUEST, "Invalid request parameters."), + /** 未认证:缺少或无效的凭证(token 缺失/过期/非法/用户已禁用)。 */ + UNAUTHORIZED("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, "Authentication is required."), + /** Bean Validation、绑定校验等字段级参数校验失败。 */ VALIDATION_FAILED("VALIDATION_FAILED", HttpStatus.BAD_REQUEST, "Invalid request parameters."), diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/common/UserContext.java b/data-agent-backend/src/main/java/io/github/malonetalk/common/UserContext.java new file mode 100644 index 0000000..4065854 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/common/UserContext.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.common; + +/** + * 当前登录用户的轻量投影,由拦截器在同步请求线程放入 ThreadLocal。 + * + *

仅承载鉴权必要字段(不含 password_hash),供管理/会话等同步 API 取用。Agent 异步链路 + * (Reactor 弹性线程)拿不到此 ThreadLocal——权限轮次会改为通过 ToolCallContext 显式传 userId。 + */ +public record UserContext(Integer userId, String username, String displayName) { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + public static void set(UserContext context) { + HOLDER.set(context); + } + + public static UserContext get() { + return HOLDER.get(); + } + + public static void clear() { + HOLDER.remove(); + } + + public static UserContext require() { + UserContext context = HOLDER.get(); + if (context == null) { + throw new IllegalStateException("No user context bound to current thread."); + } + return context; + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/config/AdminBootstrapRunner.java b/data-agent-backend/src/main/java/io/github/malonetalk/config/AdminBootstrapRunner.java new file mode 100644 index 0000000..2d06e36 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/config/AdminBootstrapRunner.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.config; + +import io.github.malonetalk.entity.SysUser; +import io.github.malonetalk.mapper.SysUserMapper; +import io.github.malonetalk.util.PasswordUtil; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +/** + * 启动引导:sys_user 为空时创建初始 admin 账号。 + * + *

初始密码取自环境变量({@code ADMIN_INIT_PASSWORD},经 {@code admin.init-password} 注入), + * 不写死进代码/SQL,避免进 git 历史。登录后应立即用改密码接口换掉。 + * + *

未配置初始密码时启动失败(fail-closed)——避免无密码 admin 账号被静默创建。 + * role_id 暂为 0:本轮无任何权限检查,admin 仅作为首个登录账号; + * 「不受权限限制」语义随权限轮次 sys_role(id=1) + @AdminOnly 一起生效。 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class AdminBootstrapRunner implements CommandLineRunner { + + private final SysUserMapper sysUserMapper; + + @Value("${admin.init-password:}") + private String adminInitPassword; + + @Override + public void run(String... args) { + if (sysUserMapper.countAll() > 0) { + return; + } + if (adminInitPassword == null || adminInitPassword.isBlank()) { + throw new IllegalStateException( + "No user exists and admin.init-password (env ADMIN_INIT_PASSWORD) is not set. " + + "Configure it before first startup to bootstrap the admin account."); + } + LocalDateTime now = LocalDateTime.now(); + SysUser admin = new SysUser(); + admin.setUsername("admin"); + admin.setPasswordHash(PasswordUtil.hash(adminInitPassword)); + admin.setDisplayName("管理员"); + admin.setRoleId(0); + admin.setIdpType("LOCAL"); + admin.setIdpUserId(null); + admin.setStatus(1); + admin.setCreateTime(now); + admin.setUpdateTime(now); + sysUserMapper.insert(admin); + log.info( + "Bootstrapped initial admin account (id={}, username=admin). Change its password" + + " ASAP.", + admin.getId()); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/config/WebMvcConfig.java b/data-agent-backend/src/main/java/io/github/malonetalk/config/WebMvcConfig.java new file mode 100644 index 0000000..211f502 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/config/WebMvcConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.config; + +import io.github.malonetalk.interceptor.AuthInterceptor; +import lombok.AllArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** 注册鉴权拦截器:覆盖 /api/**,仅放行登录接口与 Spring 错误页。 */ +@Configuration +@AllArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/auth/login", "/error"); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/AuthController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/AuthController.java new file mode 100644 index 0000000..5a9d80a --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/AuthController.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.controller; + +import io.github.malonetalk.common.ErrorCode; +import io.github.malonetalk.common.Result; +import io.github.malonetalk.common.UserContext; +import io.github.malonetalk.dto.ChangePasswordRequest; +import io.github.malonetalk.dto.LoginRequest; +import io.github.malonetalk.dto.LoginResponse; +import io.github.malonetalk.dto.UserInfoResponse; +import io.github.malonetalk.entity.SysUser; +import io.github.malonetalk.exception.BusinessException; +import io.github.malonetalk.mapper.SysUserMapper; +import io.github.malonetalk.util.JwtUtil; +import io.github.malonetalk.util.PasswordUtil; +import jakarta.validation.Valid; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +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.RestController; + +/** + * 认证接口:登录 / 当前用户 / 改密码。 + * + *

登录失败统一返回 401 + "用户名或密码错误",避免用户名枚举;账号禁用单独提示。 + */ +@RestController +@Slf4j +@AllArgsConstructor +@RequestMapping("/api/auth") +public class AuthController { + + private static final String BAD_CREDENTIALS = "用户名或密码错误"; + + private final SysUserMapper sysUserMapper; + private final JwtUtil jwtUtil; + + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginRequest request) { + SysUser user = sysUserMapper.selectByUsername(request.username()); + // 用户不存在、外部身份源(password_hash 为空)、密码不匹配:统一文案,避免枚举用户名。 + if (user == null + || user.getPasswordHash() == null + || !PasswordUtil.verify(request.password(), user.getPasswordHash())) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED, BAD_CREDENTIALS); + } + if (user.getStatus() == null || user.getStatus() != 1) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED, "账号已禁用,请联系管理员"); + } + String token = jwtUtil.generate(user.getId()); + UserInfoResponse info = + new UserInfoResponse(user.getId(), user.getUsername(), user.getDisplayName()); + return Result.success(new LoginResponse(token, info)); + } + + @GetMapping("/me") + public Result me() { + UserContext context = UserContext.require(); + return Result.success( + new UserInfoResponse(context.userId(), context.username(), context.displayName())); + } + + @PostMapping("/change-password") + public Result changePassword(@Valid @RequestBody ChangePasswordRequest request) { + Integer userId = UserContext.require().userId(); + SysUser user = sysUserMapper.selectById(userId); + if (user == null + || user.getPasswordHash() == null + || !PasswordUtil.verify(request.oldPassword(), user.getPasswordHash())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "旧密码不正确"); + } + sysUserMapper.updatePassword( + userId, PasswordUtil.hash(request.newPassword()), LocalDateTime.now()); + return Result.success(true); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/ChangePasswordRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..c6f3a51 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ChangePasswordRequest.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ChangePasswordRequest( + @NotBlank(message = "oldPassword 不能为空") String oldPassword, + @NotBlank(message = "newPassword 不能为空") + @Size(min = 6, max = 64, message = "newPassword 长度需在 6-64 之间") + String newPassword) {} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginRequest.java new file mode 100644 index 0000000..3493d14 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginRequest.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "username 不能为空") String username, + @NotBlank(message = "password 不能为空") String password) {} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginResponse.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginResponse.java new file mode 100644 index 0000000..22ebeb7 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginResponse.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +/** 登录成功响应:token + 用户信息(一次往返,前端无需再调 me)。 */ +public record LoginResponse(String token, UserInfoResponse user) {} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserInfoResponse.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserInfoResponse.java new file mode 100644 index 0000000..6c46a7f --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserInfoResponse.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +/** 当前用户信息;角色/是否管理员字段随权限轮次补充。 */ +public record UserInfoResponse(Integer userId, String username, String displayName) {} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/entity/SysUser.java b/data-agent-backend/src/main/java/io/github/malonetalk/entity/SysUser.java new file mode 100644 index 0000000..7f20665 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/entity/SysUser.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.entity; + +import java.time.LocalDateTime; +import lombok.Data; + +/** 系统用户。身份源抽象字段(idp_type/idp_user_id)本轮登录仅用 LOCAL,外部身份源对接后置。 */ +@Data +public class SysUser { + + private Integer id; + private String username; + + /** PBKDF2 哈希,格式 pbkdf2$iter$salt$hash;外部身份源用户为空。 */ + private String passwordHash; + + private String displayName; + private Integer roleId; + private String idpType; + private String idpUserId; + + /** 1=启用 0=禁用。 */ + private Integer status; + + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/interceptor/AuthInterceptor.java b/data-agent-backend/src/main/java/io/github/malonetalk/interceptor/AuthInterceptor.java new file mode 100644 index 0000000..926cd22 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/interceptor/AuthInterceptor.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.interceptor; + +import io.github.malonetalk.common.ErrorCode; +import io.github.malonetalk.common.UserContext; +import io.github.malonetalk.exception.BusinessException; +import io.github.malonetalk.mapper.SysUserMapper; +import io.github.malonetalk.util.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 鉴权拦截器:拦 /api/**,放行 /api/auth/login 与 /error。 + * + *

解析 Authorization: Bearer → JwtUtil 取 userId → 每次请求查库(selectAuthProjection 只返回启用用户) + * → 放入 UserContext。token 缺失/过期/非法 或 用户被禁用 一律抛 {@link ErrorCode#UNAUTHORIZED}, + * 由 GlobalExceptionHandler 统一输出 401。每次查库保证禁用即时生效。 + * + *

权限轮次再加 @AdminOnly 与表/列拦截;本轮登录后所有接口行为与现状一致。 + */ +@Component +@AllArgsConstructor +public class AuthInterceptor implements HandlerInterceptor { + + private final JwtUtil jwtUtil; + private final SysUserMapper sysUserMapper; + + @Override + public boolean preHandle( + HttpServletRequest request, HttpServletResponse response, Object handler) { + Integer userId = jwtUtil.parseUserId(extractBearer(request)); + if (userId == null) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED, "Missing or invalid token."); + } + UserContext context = sysUserMapper.selectAuthProjection(userId); + if (context == null) { + // 用户不存在或 status=0(禁用),均视为未授权。 + throw BusinessException.of( + ErrorCode.UNAUTHORIZED, "Account is disabled or does not exist."); + } + UserContext.set(context); + return true; + } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + UserContext.clear(); + } + + private String extractBearer(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header == null || header.isBlank()) { + return null; + } + String trimmed = header.trim(); + String prefix = "Bearer "; + if (trimmed.length() > prefix.length() + && trimmed.regionMatches(true, 0, prefix, 0, prefix.length())) { + return trimmed.substring(prefix.length()).trim(); + } + return null; + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/mapper/SysUserMapper.java b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/SysUserMapper.java new file mode 100644 index 0000000..ceefded --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/SysUserMapper.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.mapper; + +import io.github.malonetalk.common.UserContext; +import io.github.malonetalk.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface SysUserMapper { + + /** 登录校验:按用户名查 LOCAL 账号(含 password_hash)。 */ + SysUser selectByUsername(@Param("username") String username); + + SysUser selectById(@Param("id") Integer id); + + /** 拦截器每次请求调用:仅取鉴权必要字段,且 status=1 才返回;禁用即时生效。 */ + UserContext selectAuthProjection(@Param("id") Integer id); + + int insert(SysUser user); + + int updatePassword( + @Param("id") Integer id, + @Param("passwordHash") String passwordHash, + @Param("updateTime") java.time.LocalDateTime updateTime); + + int countAll(); +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/util/JwtUtil.java b/data-agent-backend/src/main/java/io/github/malonetalk/util/JwtUtil.java new file mode 100644 index 0000000..4402bce --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/util/JwtUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.util; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Date; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * JWT 签发/校验:HS256,唯一用途是承载 userId(权限每次请求现查库,不进 token)。 + * + *

密钥来源环境变量 {@code JWT_SECRET}(须 ≥ 32 字节);未配置或过短时启动期生成随机密钥并告警—— + * 此时重启会让所有已签发 token 失效,生产环境必须配置固定密钥。 + * ponytail: 不引入刷新 token / 黑名单,登出 = 前端清 token(JWT 无状态)。 + */ +@Component +@Slf4j +public class JwtUtil { + + private static final int MIN_SECRET_BYTES = 32; + + private final SecretKey key; + private final long expirationMillis; + + public JwtUtil( + @Value("${jwt.secret:}") String secret, + @Value("${jwt.expiration-hours:24}") long expirationHours) { + this.key = resolveKey(secret); + this.expirationMillis = expirationHours * 3600_000L; + } + + /** + * 签发 token,subject 为 userId 字符串。 + */ + public String generate(Integer userId) { + Date now = new Date(); + return Jwts.builder() + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(new Date(now.getTime() + expirationMillis)) + .signWith(key) + .compact(); + } + + /** + * 解析 userId;token 非法/过期返回 null,由拦截器据此返回 401。 + */ + public Integer parseUserId(String token) { + if (token == null || token.isBlank()) { + return null; + } + try { + String subject = + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + return Integer.valueOf(subject); + } catch (Exception e) { + return null; + } + } + + private static SecretKey resolveKey(String secret) { + if (secret != null && secret.getBytes(StandardCharsets.UTF_8).length >= MIN_SECRET_BYTES) { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + log.warn( + "jwt.secret is missing or shorter than {} bytes; generating an in-memory random" + + " key. All tokens will invalidate on restart. Configure JWT_SECRET for" + + " production.", + MIN_SECRET_BYTES); + // ponytail: 直接用随机字节走 hmacShaKeyFor,避开 jjwt 0.12.x secretKeyFor 重载歧义。 + byte[] randomBytes = new byte[MIN_SECRET_BYTES]; + new SecureRandom().nextBytes(randomBytes); + return Keys.hmacShaKeyFor(randomBytes); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/util/PasswordUtil.java b/data-agent-backend/src/main/java/io/github/malonetalk/util/PasswordUtil.java new file mode 100644 index 0000000..d2aac47 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/util/PasswordUtil.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.util; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +/** + * 密码哈希:JDK 标准库 PBKDF2WithHmacSHA256,零新依赖。 + * + *

存储格式 {@code pbkdf2$$$};迭代次数与盐长度固定,校验时从串中解析。 + * OWASP 2023 推荐 PBKDF2-SHA256 迭代 ≥ 600000,此处取 210000(兼顾百人内网规模与登录耗时), + * ponytail: 如安全规范要求更高强度或换 Argon2,调迭代数或换算法即可,存储格式兼容。 + */ +public final class PasswordUtil { + + private static final String ALGORITHM = "PBKDF2WithHmacSHA256"; + private static final int ITERATIONS = 210000; + private static final int SALT_BYTES = 16; + private static final int HASH_BITS = 256; + private static final String PREFIX = "pbkdf2"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private PasswordUtil() {} + + /** + * 生成 {@code pbkdf2$iter$salt$hash} 串。 + */ + public static String hash(String password) { + if (password == null || password.isBlank()) { + throw new IllegalArgumentException("Password must not be blank."); + } + byte[] salt = new byte[SALT_BYTES]; + RANDOM.nextBytes(salt); + byte[] hash = derive(password, salt, ITERATIONS); + return PREFIX + "$" + ITERATIONS + "$" + base64(salt) + "$" + base64(hash); + } + + /** + * 校验明文与已存储的哈希串是否匹配;存储串格式非法或为 null 一律返回 false。 + */ + public static boolean verify(String password, String stored) { + if (password == null || stored == null) { + return false; + } + String[] parts = stored.split("\\$"); + if (parts.length != 4 || !PREFIX.equals(parts[0])) { + return false; + } + try { + int iterations = Integer.parseInt(parts[1]); + byte[] salt = Base64.getDecoder().decode(parts[2]); + byte[] expected = Base64.getDecoder().decode(parts[3]); + byte[] actual = derive(password, salt, iterations); + return constantTimeEquals(expected, actual); + } catch (IllegalArgumentException e) { + // NumberFormatException 是 IllegalArgumentException 子类,已一并覆盖。 + return false; + } + } + + private static byte[] derive(String password, byte[] salt, int iterations) { + try { + SecretKeyFactory factory = SecretKeyFactory.getInstance(ALGORITHM); + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, HASH_BITS); + SecretKey key = factory.generateSecret(spec); + return key.getEncoded(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("PBKDF2WithHmacSHA256 unavailable", e); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed to derive password hash", e); + } + } + + private static String base64(byte[] bytes) { + return Base64.getEncoder().withoutPadding().encodeToString(bytes); + } + + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + int diff = 0; + for (int i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } +} diff --git a/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml b/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml new file mode 100644 index 0000000..f43caa3 --- /dev/null +++ b/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO sys_user ( + username, password_hash, display_name, role_id, idp_type, idp_user_id, status, + create_time, update_time + ) VALUES ( + #{username}, #{passwordHash}, #{displayName}, #{roleId}, #{idpType}, #{idpUserId}, #{status}, + #{createTime}, #{updateTime} + ) + + + + UPDATE sys_user + SET password_hash = #{passwordHash}, update_time = #{updateTime} + WHERE id = #{id} + + + + + diff --git a/data-agent-frontend/src/App.vue b/data-agent-frontend/src/App.vue index a0da076..075ffe0 100644 --- a/data-agent-frontend/src/App.vue +++ b/data-agent-frontend/src/App.vue @@ -16,13 +16,19 @@ -->