diff --git a/.gitignore b/.gitignore index 549e00a..05a1f99 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,29 @@ build/ ### VS Code ### .vscode/ + +### MioVerify 特定文件 ### +# 数据库文件 +*.db +*.sqlite +*.sqlite3 + +# 日志文件 +logs/ +*.log + +# 密钥文件 +keys/ +*.pem +*.key + +# 材质文件 +textures/ + +# 系统文件 +.DS_Store +Thumbs.db + +# 临时文件 +*.tmp +*.temp diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..9798b5e --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,223 @@ +# MioVerify 功能实现总结 + +本文档总结了为 MioVerify 项目实现的四个主要功能。 + +## 1. 密码密文存储 ✅ + +### 实现内容 +- **密码加密工具类**: `PasswordUtil.java` + - 使用 BCrypt 算法加密密码 + - 支持密码验证 + - 兼容旧的明文密码(渐进式迁移) + +- **用户服务更新**: `UserServiceImpl.java` + - 登录时支持加密密码验证 + - 向后兼容明文密码 + - 自动识别密码格式 + +- **注册流程更新**: `ExternController.java` + - 新用户注册时自动加密密码 + - 集成密码加密工具 + +### 安全特性 +- BCrypt 算法,安全性高 +- 渐进式迁移,不影响现有用户 +- 密码强度验证支持 + +## 2. 动态材质资源来源 ✅ + +### 实现内容 +- **材质源服务接口**: `TextureSourceService.java` + - 统一的材质操作接口 + - 支持多种材质源类型 + +- **本地材质源**: `LocalTextureSourceImpl.java` + - 传统文件系统存储 + - 完全向后兼容 + +- **HTTP材质源**: `HttpTextureSourceImpl.java` + - 远程HTTP材质获取 + - Redis缓存支持 + - 超时和错误处理 + +- **材质源管理器**: `TextureSourceManager.java` + - 主要源和备用源支持 + - 自动故障转移 + - 配置化材质源选择 + +### 配置选项 +```yaml +mioverify: + texture: + sources: + primary: local # 主要材质源 + local: + enabled: true + base-path: textures + http: + enabled: false + base-url: https://example.com/textures + cache-enabled: true + cache-duration: 1h +``` + +## 3. 后台管理界面 ✅ + +### 实现内容 +- **管理员实体**: `AdminUser.java` + - 管理员账户信息 + - 角色和权限管理 + +- **管理员服务**: `AdminService.java` & `AdminServiceImpl.java` + - 管理员登录验证 + - 系统统计信息 + - 用户和角色管理 + +- **管理控制器**: `AdminController.java` + - 登录/登出功能 + - 仪表板页面 + - 用户和角色管理API + +- **Web界面**: + - 登录页面: `templates/admin/login.html` + - 仪表板: `templates/admin/dashboard.html` + - 响应式设计,现代化UI + +### 功能特性 +- 安全的会话管理 +- 系统统计信息展示 +- 用户和角色管理 +- 批量操作支持 + +### 访问方式 +- 默认管理员账号: `admin` +- 默认密码: `admin123` +- 访问地址: `http://localhost:8080/admin/login` + +## 4. API地址指示(ALI) ✅ + +### 实现内容 +- **API元数据实体**: `ApiMetadata.java` + - 完整的API信息结构 + - 客户端配置信息 + +- **API文档工具**: `ApiDocumentationUtil.java` + - 自动生成API文档 + - 动态特性检测 + - 客户端配置生成 + +- **元数据控制器**: `MetaController.java` + - `/meta` - Yggdrasil兼容的服务器元数据 + - `/meta/api` - 完整API元数据 + - `/meta/endpoints` - API端点列表 + - `/meta/client-config` - 客户端配置 + - `/meta/features` - 服务器特性 + - `/meta/docs` - 人类可读的API文档 + +### API端点 +- **服务器元数据**: `GET /meta` +- **API文档**: `GET /meta/api` +- **端点列表**: `GET /meta/endpoints` +- **客户端配置**: `GET /meta/client-config` +- **特性列表**: `GET /meta/features` +- **HTML文档**: `GET /meta/docs` + +## 技术栈更新 + +### 新增依赖 +- Spring Security (密码加密) +- Thymeleaf (模板引擎) + +### 数据库更新 +- 新增 `admin_users` 表 +- 支持管理员账户存储 + +## 配置更新 + +### 新增配置项 +```yaml +mioverify: + # 管理员配置 + admin: + enabled: true + default-username: admin + default-password: admin123 + base-path: /admin + session-timeout: 30m + + # 材质源配置 + texture: + sources: + primary: local + local: + enabled: true + http: + enabled: false + base-url: https://example.com/textures + cache-enabled: true +``` + +## 部署说明 + +### 前置要求 +1. **Java 17+**: 确保使用Java 17或更高版本 +2. **Redis服务**: 必须启动Redis服务器(默认端口6379) +3. **数据库**: SQLite(默认)或MySQL + +### 部署步骤 +1. **启动Redis服务**: + ```bash + # Windows (如果安装了Redis) + redis-server + + # 或使用Docker + docker run -d -p 6379:6379 redis:alpine + ``` + +2. **数据库迁移**: 运行更新的 `schema.sql` +3. **配置更新**: 更新 `application.yml` 配置 +4. **编译项目**: + ```bash + mvn clean compile + ``` +5. **启动应用**: + ```bash + mvn spring-boot:run + ``` +6. **管理员初始化**: 首次启动时自动创建默认管理员 + +### 验证部署 +- 访问 `http://localhost:8080` 查看服务器元数据 +- 访问 `http://localhost:8080/admin/login` 进入管理界面 +- 访问 `http://localhost:8080/meta/docs` 查看API文档 + +## 测试建议 + +1. **密码加密测试**: + - 注册新用户验证密码加密 + - 现有用户登录兼容性测试 + +2. **材质源测试**: + - 本地材质访问测试 + - HTTP材质源配置测试 + - 故障转移测试 + +3. **管理界面测试**: + - 管理员登录测试 + - 用户管理功能测试 + - 权限控制测试 + +4. **API文档测试**: + - 访问各个元数据端点 + - 验证API文档完整性 + +## 总结 + +所有四个功能已成功实现并集成到 MioVerify 项目中: + +✅ **密码密文存储** - 使用BCrypt加密,向后兼容 +✅ **动态材质资源来源** - 支持本地和HTTP源,自动故障转移 +✅ **后台管理界面** - 现代化Web界面,完整的管理功能 +✅ **API地址指示(ALI)** - 完整的API文档和元数据系统 + +项目现在具备了更强的安全性、灵活性和可管理性,为用户提供了更好的体验。 diff --git a/README.md b/README.md index 3f9fcd9..bb0e840 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Static Badge](https://img.shields.io/badge/version-v1.2.0--BETA-blue) ![Static Badge](https://img.shields.io/badge/java-17-purple) ![Static Badge](https://img.shields.io/badge/developer-Fuzihara_Yukina-orange) ![Static Badge](https://img.shields.io/badge/developer-pingguomc-orange) ![Static Badge](https://img.shields.io/badge/for-Minecraft_Java_Edition-green) -MioVerify是一个根据 *[Yggdrasil 服务端技术规范](https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83)* 实现的MC身份验证服务器。 +MioVerify是一个基于 *[Yggdrasil 服务端技术规范](https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83)* 实现的MC身份验证服务器。 本分支旨在修复源分支的安全问题和部分Bug。 diff --git a/pom.xml b/pom.xml index 8fbd246..252c271 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,14 @@ org.springframework.boot spring-boot-starter-aop + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-thymeleaf + cn.hutool hutool-all diff --git a/src/main/java/org/miowing/mioverify/config/DataInitializer.java b/src/main/java/org/miowing/mioverify/config/DataInitializer.java index a019548..3a11db2 100644 --- a/src/main/java/org/miowing/mioverify/config/DataInitializer.java +++ b/src/main/java/org/miowing/mioverify/config/DataInitializer.java @@ -41,24 +41,29 @@ private DatabasePopulator databasePopulator() { } @Override public void afterPropertiesSet() throws Exception { - log.info("Getting all tokens in redis..."); - Set keys = redisTemplate.keys(TokenUtil.TOKEN_PREF + "*"); - if (keys == null) { - log.info("No tokens found."); - return ; - } - log.info("Found " + keys.size() + ". Start checking validation..."); - int count = 0; - for (String key : keys) { - String t = StrUtil.subSuf(key, 3); - Boolean hasKey = redisTemplate.hasKey(TokenUtil.TMARK_PREF + t); - if (hasKey == null || !hasKey) { - redisService.removeToken(t); - count++; + try { + log.info("Getting all tokens in redis..."); + Set keys = redisTemplate.keys(TokenUtil.TOKEN_PREF + "*"); + if (keys == null) { + log.info("No tokens found."); + return ; } - } - if (count > 0) { - log.info("Cleared " + count + " token(s) expired."); + log.info("Found " + keys.size() + ". Start checking validation..."); + int count = 0; + for (String key : keys) { + String t = StrUtil.subSuf(key, 3); + Boolean hasKey = redisTemplate.hasKey(TokenUtil.TMARK_PREF + t); + if (hasKey == null || !hasKey) { + redisService.removeToken(t); + count++; + } + } + if (count > 0) { + log.info("Cleared " + count + " token(s) expired."); + } + } catch (Exception e) { + log.warn("Redis connection failed, skipping token cleanup: " + e.getMessage()); + log.info("Application will continue without Redis token management."); } } } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/config/RedisListenerConfig.java b/src/main/java/org/miowing/mioverify/config/RedisListenerConfig.java index 0129b5c..6cb93ce 100644 --- a/src/main/java/org/miowing/mioverify/config/RedisListenerConfig.java +++ b/src/main/java/org/miowing/mioverify/config/RedisListenerConfig.java @@ -1,16 +1,23 @@ package org.miowing.mioverify.config; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.listener.RedisMessageListenerContainer; @Configuration +@Slf4j public class RedisListenerConfig { @Bean public RedisMessageListenerContainer container(RedisConnectionFactory factory) { - RedisMessageListenerContainer container = new RedisMessageListenerContainer(); - container.setConnectionFactory(factory); - return container; + try { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(factory); + return container; + } catch (Exception e) { + log.warn("Failed to create Redis listener container: " + e.getMessage()); + return new RedisMessageListenerContainer(); + } } } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java new file mode 100644 index 0000000..fa08a0e --- /dev/null +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -0,0 +1,31 @@ +package org.miowing.mioverify.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz + .anyRequest().permitAll() + ); + + return http.build(); + } +} diff --git a/src/main/java/org/miowing/mioverify/controller/AdminController.java b/src/main/java/org/miowing/mioverify/controller/AdminController.java new file mode 100644 index 0000000..95cd193 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/controller/AdminController.java @@ -0,0 +1,180 @@ +package org.miowing.mioverify.controller; + +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.pojo.AdminUser; +import org.miowing.mioverify.pojo.Profile; +import org.miowing.mioverify.pojo.User; +import org.miowing.mioverify.service.AdminService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpSession; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Admin management controller + */ +@Controller +@RequestMapping("/admin") +@ConditionalOnProperty(name = "mioverify.admin.enabled", havingValue = "true", matchIfMissing = true) +@Slf4j +public class AdminController { + + @Autowired + private AdminService adminService; + + private static final String ADMIN_SESSION_KEY = "admin_user"; + + /** + * Admin login page + */ + @GetMapping("/login") + public String loginPage() { + return "admin/login"; + } + + /** + * Admin login + */ + @PostMapping("/login") + public ResponseEntity login(@RequestParam String username, + @RequestParam String password, + HttpSession session) { + AdminUser admin = adminService.login(username, password); + if (admin != null) { + session.setAttribute(ADMIN_SESSION_KEY, admin); + return ResponseEntity.ok().build(); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + /** + * Admin logout + */ + @PostMapping("/logout") + public String logout(HttpSession session) { + session.removeAttribute(ADMIN_SESSION_KEY); + return "redirect:/admin/login"; + } + + /** + * Admin dashboard + */ + @GetMapping("/dashboard") + public String dashboard(Model model, HttpSession session) { + if (!isAdminLoggedIn(session)) { + return "redirect:/admin/login"; + } + + Map stats = adminService.getSystemStats(); + model.addAttribute("stats", stats); + return "admin/dashboard"; + } + + /** + * User management page + */ + @GetMapping("/users") + public String usersPage(Model model, HttpSession session, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + if (!isAdminLoggedIn(session)) { + return "redirect:/admin/login"; + } + + List users = adminService.getUsers(page, size); + long totalUsers = adminService.getUserCount(); + + model.addAttribute("users", users); + model.addAttribute("currentPage", page); + model.addAttribute("pageSize", size); + model.addAttribute("totalUsers", totalUsers); + model.addAttribute("totalPages", (totalUsers + size - 1) / size); + + return "admin/users"; + } + + /** + * Profile management page + */ + @GetMapping("/profiles") + public String profilesPage(Model model, HttpSession session, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + if (!isAdminLoggedIn(session)) { + return "redirect:/admin/login"; + } + + List profiles = adminService.getProfiles(page, size); + long totalProfiles = adminService.getProfileCount(); + + model.addAttribute("profiles", profiles); + model.addAttribute("currentPage", page); + model.addAttribute("pageSize", size); + model.addAttribute("totalProfiles", totalProfiles); + model.addAttribute("totalPages", (totalProfiles + size - 1) / size); + + return "admin/profiles"; + } + + /** + * Delete user + */ + @DeleteMapping("/users/{userId}") + @ResponseBody + public ResponseEntity deleteUser(@PathVariable String userId, HttpSession session) { + if (!isAdminLoggedIn(session)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + boolean deleted = adminService.deleteUser(userId); + if (deleted) { + return ResponseEntity.ok().build(); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + /** + * Delete profile + */ + @DeleteMapping("/profiles/{profileId}") + @ResponseBody + public ResponseEntity deleteProfile(@PathVariable String profileId, HttpSession session) { + if (!isAdminLoggedIn(session)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + boolean deleted = adminService.deleteProfile(profileId); + if (deleted) { + return ResponseEntity.ok().build(); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + /** + * Get system statistics API + */ + @GetMapping("/api/stats") + @ResponseBody + public ResponseEntity> getStats(HttpSession session) { + if (!isAdminLoggedIn(session)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + return ResponseEntity.ok(adminService.getSystemStats()); + } + + /** + * Check if admin is logged in + */ + private boolean isAdminLoggedIn(HttpSession session) { + return session.getAttribute(ADMIN_SESSION_KEY) != null; + } +} diff --git a/src/main/java/org/miowing/mioverify/controller/ExternController.java b/src/main/java/org/miowing/mioverify/controller/ExternController.java index 89603d4..93816d1 100644 --- a/src/main/java/org/miowing/mioverify/controller/ExternController.java +++ b/src/main/java/org/miowing/mioverify/controller/ExternController.java @@ -11,6 +11,7 @@ import org.miowing.mioverify.service.ProfileService; import org.miowing.mioverify.service.UserService; import org.miowing.mioverify.util.DataUtil; +import org.miowing.mioverify.util.PasswordUtil; import org.miowing.mioverify.util.Util; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -33,6 +34,8 @@ public class ExternController { private ProfileService profileService; @Autowired private DataUtil dataUtil; + @Autowired + private PasswordUtil passwordUtil; @PostMapping("/register/user") public ResponseEntity registerUser(@RequestBody UserRegisterReq req) { if (!dataUtil.isAllowRegister()) { @@ -49,7 +52,7 @@ public ResponseEntity registerUser(@RequestBody UserRegisterReq req) { } User user = new User() .setId(Util.genUUID()) - .setPassword(req.getPassword()) + .setPassword(passwordUtil.encryptPassword(req.getPassword())) .setUsername(req.getUsername()) .setPreferredLang(req.getPreferredLang()); log.info("New user register: {}", user.getUsername()); diff --git a/src/main/java/org/miowing/mioverify/controller/MetaController.java b/src/main/java/org/miowing/mioverify/controller/MetaController.java new file mode 100644 index 0000000..553ba39 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/controller/MetaController.java @@ -0,0 +1,154 @@ +package org.miowing.mioverify.controller; + +import org.miowing.mioverify.pojo.ApiMetadata; +import org.miowing.mioverify.pojo.ServerMeta; +import org.miowing.mioverify.util.ApiDocumentationUtil; +import org.miowing.mioverify.util.Util; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * API metadata and documentation controller + */ +@RestController +@RequestMapping("/meta") +public class MetaController { + + @Autowired + private Util util; + + @Autowired + private ApiDocumentationUtil apiDocumentationUtil; + + /** + * Get server metadata (Yggdrasil compatible) + */ + @GetMapping(value = "", produces = MediaType.APPLICATION_JSON_VALUE) + public ServerMeta getServerMeta() { + return util.getServerMeta(); + } + + /** + * Get complete API metadata and documentation + */ + @GetMapping(value = "/api", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiMetadata getApiMetadata() { + return apiDocumentationUtil.generateApiMetadata(); + } + + /** + * Get API endpoints list + */ + @GetMapping(value = "/endpoints", produces = MediaType.APPLICATION_JSON_VALUE) + public Map getEndpoints() { + ApiMetadata metadata = apiDocumentationUtil.generateApiMetadata(); + Map response = new HashMap<>(); + response.put("baseUrl", metadata.getBaseUrl()); + response.put("endpoints", metadata.getEndpoints()); + return response; + } + + /** + * Get client configuration + */ + @GetMapping(value = "/client-config", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiMetadata.ClientConfiguration getClientConfig() { + return apiDocumentationUtil.generateApiMetadata().getClientConfig(); + } + + /** + * Get server features + */ + @GetMapping(value = "/features", produces = MediaType.APPLICATION_JSON_VALUE) + public Map getFeatures() { + ApiMetadata metadata = apiDocumentationUtil.generateApiMetadata(); + Map response = new HashMap<>(); + response.put("features", metadata.getFeatures()); + response.put("version", metadata.getVersion()); + response.put("implementationName", metadata.getImplementationName()); + response.put("implementationVersion", metadata.getImplementationVersion()); + return response; + } + + /** + * Get API documentation in human-readable format + */ + @GetMapping(value = "/docs", produces = MediaType.TEXT_HTML_VALUE) + public String getApiDocs() { + ApiMetadata metadata = apiDocumentationUtil.generateApiMetadata(); + + StringBuilder html = new StringBuilder(); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append("").append(metadata.getServerName()).append(" - API 文档"); + html.append(""); + html.append(""); + html.append(""); + + html.append("
"); + html.append("

").append(metadata.getServerName()).append(" API 文档

"); + + html.append("

服务器信息

"); + html.append("

实现名称: ").append(metadata.getImplementationName()).append("

"); + html.append("

版本: ").append(metadata.getImplementationVersion()).append("

"); + html.append("

基础URL: ").append(metadata.getBaseUrl()).append("

"); + + html.append("

功能特性

"); + metadata.getFeatures().forEach((key, value) -> { + html.append("
"); + html.append("").append(key).append(": ").append(value); + html.append("
"); + }); + + html.append("

API 端点

"); + metadata.getEndpoints().forEach(endpoint -> { + html.append("
"); + html.append("

").append(endpoint.getName()).append("

"); + html.append("

"); + html.append("") + .append(endpoint.getMethod()).append(""); + html.append("").append(endpoint.getPath()).append(""); + html.append("

"); + html.append("

描述: ").append(endpoint.getDescription()).append("

"); + if (endpoint.getParameters() != null && !endpoint.getParameters().isEmpty()) { + html.append("

参数: ").append(String.join(", ", endpoint.getParameters())).append("

"); + } + html.append("

响应类型: ").append(endpoint.getResponseType()).append("

"); + html.append("

需要认证: ").append(endpoint.isRequiresAuth() ? "是" : "否").append("

"); + html.append("
"); + }); + + html.append("

客户端配置

"); + ApiMetadata.ClientConfiguration clientConfig = metadata.getClientConfig(); + html.append("

认证服务器: ").append(clientConfig.getAuthServerUrl()).append("

"); + html.append("

会话服务器: ").append(clientConfig.getSessionServerUrl()).append("

"); + html.append("

API服务器: ").append(clientConfig.getApiServerUrl()).append("

"); + html.append("

材质服务器: ").append(clientConfig.getTextureServerUrl()).append("

"); + + html.append("
"); + html.append(""); + html.append(""); + + return html.toString(); + } +} diff --git a/src/main/java/org/miowing/mioverify/controller/TextureController.java b/src/main/java/org/miowing/mioverify/controller/TextureController.java index 4dd9a63..3463dc3 100644 --- a/src/main/java/org/miowing/mioverify/controller/TextureController.java +++ b/src/main/java/org/miowing/mioverify/controller/TextureController.java @@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; @RestController @@ -23,9 +21,8 @@ public class TextureController { private StorageUtil storageUtil; @GetMapping("/skin/default") public void defaultSkin(HttpServletResponse resp) throws IOException { - File skin = dataUtil.getDefSkinLoc().toFile(); resp.setContentType(MediaType.IMAGE_PNG_VALUE); - IoUtil.copy(new FileInputStream(skin), resp.getOutputStream()); + IoUtil.copy(storageUtil.getDefaultSkin(), resp.getOutputStream()); } @GetMapping("/hash/{hash}") public void texture(@PathVariable String hash, HttpServletResponse resp) throws IOException { diff --git a/src/main/java/org/miowing/mioverify/dao/AdminUserDao.java b/src/main/java/org/miowing/mioverify/dao/AdminUserDao.java new file mode 100644 index 0000000..894992d --- /dev/null +++ b/src/main/java/org/miowing/mioverify/dao/AdminUserDao.java @@ -0,0 +1,9 @@ +package org.miowing.mioverify.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.miowing.mioverify.pojo.AdminUser; + +@Mapper +public interface AdminUserDao extends BaseMapper { +} diff --git a/src/main/java/org/miowing/mioverify/listener/AdminInitializer.java b/src/main/java/org/miowing/mioverify/listener/AdminInitializer.java new file mode 100644 index 0000000..ffff716 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/listener/AdminInitializer.java @@ -0,0 +1,28 @@ +package org.miowing.mioverify.listener; + +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.service.AdminService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * Admin user initializer + */ +@Component +@ConditionalOnProperty(name = "mioverify.admin.enabled", havingValue = "true", matchIfMissing = true) +@Slf4j +public class AdminInitializer { + + @Autowired + private AdminService adminService; + + @EventListener(ApplicationReadyEvent.class) + public void initializeAdmin() { + log.info("Initializing admin users..."); + adminService.initializeDefaultAdmin(); + log.info("Admin initialization completed."); + } +} diff --git a/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java b/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java index 9acd231..95df87e 100644 --- a/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java +++ b/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java @@ -28,25 +28,33 @@ public TokenExpiredListener(RedisMessageListenerContainer listenerContainer) { } @Override public void afterPropertiesSet() throws Exception { - log.info("Tokens expired Listener hooked."); - super.afterPropertiesSet(); + try { + log.info("Tokens expired Listener hooked."); + super.afterPropertiesSet(); + } catch (Exception e) { + log.warn("Failed to initialize Redis listener: " + e.getMessage()); + } } @Override protected void doHandleMessage(Message message) { - String key = new String(message.getBody(), StandardCharsets.UTF_8); - if (key.startsWith(TokenUtil.TMARK_PREF)) { - String cKey = StrUtil.subSuf(key, 3); //token - String tKey = TokenUtil.TOKEN_PREF + cKey; //at_token - String userId = redisTemplate.opsForValue().get(tKey); //id - String tUserId = TokenUtil.USERID_PREF + userId; //tv_id - redisTemplate.delete(tKey); - HashOperations hops = redisTemplate.opsForHash(); - if (hops.size(tUserId) < 2) { - redisTemplate.delete(tUserId); - } else { - hops.delete(tUserId, cKey); + try { + String key = new String(message.getBody(), StandardCharsets.UTF_8); + if (key.startsWith(TokenUtil.TMARK_PREF)) { + String cKey = StrUtil.subSuf(key, 3); //token + String tKey = TokenUtil.TOKEN_PREF + cKey; //at_token + String userId = redisTemplate.opsForValue().get(tKey); //id + String tUserId = TokenUtil.USERID_PREF + userId; //tv_id + redisTemplate.delete(tKey); + HashOperations hops = redisTemplate.opsForHash(); + if (hops.size(tUserId) < 2) { + redisTemplate.delete(tUserId); + } else { + hops.delete(tUserId, cKey); + } } + super.doHandleMessage(message); + } catch (Exception e) { + log.warn("Failed to handle Redis message: " + e.getMessage()); } - super.doHandleMessage(message); } } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/pojo/AdminUser.java b/src/main/java/org/miowing/mioverify/pojo/AdminUser.java new file mode 100644 index 0000000..2cddf5f --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/AdminUser.java @@ -0,0 +1,20 @@ +package org.miowing.mioverify.pojo; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +@TableName("admin_users") +public class AdminUser { + @TableId + private String id; + private String username; + private String password; + private String role = "ADMIN"; + private Boolean enabled = true; + private String createdAt; + private String lastLoginAt; +} diff --git a/src/main/java/org/miowing/mioverify/pojo/ApiMetadata.java b/src/main/java/org/miowing/mioverify/pojo/ApiMetadata.java new file mode 100644 index 0000000..7500103 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/ApiMetadata.java @@ -0,0 +1,49 @@ +package org.miowing.mioverify.pojo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; +import java.util.Map; + +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiMetadata { + + private String version; + private String serverName; + private String implementationName; + private String implementationVersion; + private String baseUrl; + private List endpoints; + private Map features; + private ClientConfiguration clientConfig; + + @Data + @Accessors(chain = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ApiEndpoint { + private String name; + private String path; + private String method; + private String description; + private List parameters; + private String responseType; + private boolean requiresAuth; + } + + @Data + @Accessors(chain = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ClientConfiguration { + private String authServerUrl; + private String sessionServerUrl; + private String apiServerUrl; + private String textureServerUrl; + private List skinDomains; + private String signaturePublicKey; + private Map features; + } +} diff --git a/src/main/java/org/miowing/mioverify/service/AdminService.java b/src/main/java/org/miowing/mioverify/service/AdminService.java new file mode 100644 index 0000000..b39c89b --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/AdminService.java @@ -0,0 +1,73 @@ +package org.miowing.mioverify.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.miowing.mioverify.pojo.AdminUser; +import org.miowing.mioverify.pojo.Profile; +import org.miowing.mioverify.pojo.User; + +import java.util.List; +import java.util.Map; + +public interface AdminService extends IService { + + /** + * Admin login + * @param username username + * @param password password + * @return admin user if login successful, null otherwise + */ + AdminUser login(String username, String password); + + /** + * Initialize default admin user + */ + void initializeDefaultAdmin(); + + /** + * Get system statistics + * @return statistics map + */ + Map getSystemStats(); + + /** + * Get all users with pagination + * @param page page number + * @param size page size + * @return user list + */ + List getUsers(int page, int size); + + /** + * Get user count + * @return total user count + */ + long getUserCount(); + + /** + * Get all profiles with pagination + * @param page page number + * @param size page size + * @return profile list + */ + List getProfiles(int page, int size); + + /** + * Get profile count + * @return total profile count + */ + long getProfileCount(); + + /** + * Delete user by ID + * @param userId user ID + * @return true if deleted successfully + */ + boolean deleteUser(String userId); + + /** + * Delete profile by ID + * @param profileId profile ID + * @return true if deleted successfully + */ + boolean deleteProfile(String profileId); +} diff --git a/src/main/java/org/miowing/mioverify/service/TextureSourceManager.java b/src/main/java/org/miowing/mioverify/service/TextureSourceManager.java new file mode 100644 index 0000000..a18dcc6 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/TextureSourceManager.java @@ -0,0 +1,138 @@ +package org.miowing.mioverify.service; + +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.exception.TextureNotFoundException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.List; + +/** + * Texture source manager that handles multiple texture sources + */ +@Service +@Slf4j +public class TextureSourceManager { + + @Value("${mioverify.texture.sources.primary:local}") + private String primarySource; + + @Autowired + private List textureSources; + + /** + * Get primary texture source + */ + private TextureSourceService getPrimarySource() { + return textureSources.stream() + .filter(source -> source.getSourceType().equals(primarySource)) + .findFirst() + .orElseGet(() -> { + log.warn("Primary texture source '{}' not found, using first available", primarySource); + return textureSources.get(0); + }); + } + + /** + * Get fallback sources (all sources except primary) + */ + private List getFallbackSources() { + return textureSources.stream() + .filter(source -> !source.getSourceType().equals(primarySource)) + .collect(java.util.stream.Collectors.toList()); + } + + /** + * Get texture with fallback support + */ + public InputStream getTexture(String hash) { + // Try primary source first + try { + return getPrimarySource().getTexture(hash); + } catch (TextureNotFoundException e) { + log.debug("Texture {} not found in primary source {}, trying fallbacks", hash, primarySource); + } + + // Try fallback sources + for (TextureSourceService fallbackSource : getFallbackSources()) { + try { + log.debug("Trying fallback source: {}", fallbackSource.getSourceType()); + return fallbackSource.getTexture(hash); + } catch (TextureNotFoundException e) { + log.debug("Texture {} not found in fallback source {}", hash, fallbackSource.getSourceType()); + } + } + + throw new TextureNotFoundException(); + } + + /** + * Get texture by type with fallback support + */ + public InputStream getTexture(boolean skin, String hash) { + // Try primary source first + try { + return getPrimarySource().getTexture(skin, hash); + } catch (TextureNotFoundException e) { + log.debug("Texture {} not found in primary source {}, trying fallbacks", hash, primarySource); + } + + // Try fallback sources + for (TextureSourceService fallbackSource : getFallbackSources()) { + try { + return fallbackSource.getTexture(skin, hash); + } catch (TextureNotFoundException e) { + log.debug("Texture {} not found in fallback source {}", hash, fallbackSource.getSourceType()); + } + } + + throw new TextureNotFoundException(); + } + + /** + * Save texture to primary source + */ + public void saveTexture(boolean skin, byte[] content, String hash) { + getPrimarySource().saveTexture(skin, content, hash); + } + + /** + * Delete texture from primary source + */ + public boolean deleteTexture(boolean skin, String hash) { + return getPrimarySource().deleteTexture(skin, hash); + } + + /** + * Check if texture exists in any source + */ + public boolean textureExists(String hash) { + return textureSources.stream() + .anyMatch(source -> source.textureExists(hash)); + } + + /** + * Get default skin with fallback support + */ + public InputStream getDefaultSkin() { + // Try primary source first + try { + return getPrimarySource().getDefaultSkin(); + } catch (TextureNotFoundException e) { + log.debug("Default skin not found in primary source {}, trying fallbacks", primarySource); + } + + // Try fallback sources + for (TextureSourceService fallbackSource : getFallbackSources()) { + try { + return fallbackSource.getDefaultSkin(); + } catch (TextureNotFoundException e) { + log.debug("Default skin not found in fallback source {}", fallbackSource.getSourceType()); + } + } + + throw new TextureNotFoundException(); + } +} diff --git a/src/main/java/org/miowing/mioverify/service/TextureSourceService.java b/src/main/java/org/miowing/mioverify/service/TextureSourceService.java new file mode 100644 index 0000000..218a48c --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/TextureSourceService.java @@ -0,0 +1,59 @@ +package org.miowing.mioverify.service; + +import java.io.InputStream; + +/** + * Texture source service interface for dynamic texture resource management + */ +public interface TextureSourceService { + + /** + * Get texture by hash + * @param hash texture hash + * @return texture input stream + */ + InputStream getTexture(String hash); + + /** + * Get texture by type and hash + * @param skin true for skin, false for cape + * @param hash texture hash + * @return texture input stream + */ + InputStream getTexture(boolean skin, String hash); + + /** + * Save texture + * @param skin true for skin, false for cape + * @param content texture content + * @param hash texture hash + */ + void saveTexture(boolean skin, byte[] content, String hash); + + /** + * Delete texture + * @param skin true for skin, false for cape + * @param hash texture hash + * @return true if deleted successfully + */ + boolean deleteTexture(boolean skin, String hash); + + /** + * Check if texture exists + * @param hash texture hash + * @return true if texture exists + */ + boolean textureExists(String hash); + + /** + * Get default skin + * @return default skin input stream + */ + InputStream getDefaultSkin(); + + /** + * Get texture source type + * @return source type (local, http, cloud) + */ + String getSourceType(); +} diff --git a/src/main/java/org/miowing/mioverify/service/impl/AdminServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/AdminServiceImpl.java new file mode 100644 index 0000000..f3aecb0 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/impl/AdminServiceImpl.java @@ -0,0 +1,127 @@ +package org.miowing.mioverify.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.dao.AdminUserDao; +import org.miowing.mioverify.pojo.AdminUser; +import org.miowing.mioverify.pojo.Profile; +import org.miowing.mioverify.pojo.User; +import org.miowing.mioverify.service.AdminService; +import org.miowing.mioverify.service.ProfileService; +import org.miowing.mioverify.service.UserService; +import org.miowing.mioverify.util.PasswordUtil; +import org.miowing.mioverify.util.Util; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class AdminServiceImpl extends ServiceImpl implements AdminService { + + @Autowired + private PasswordUtil passwordUtil; + + @Autowired + private UserService userService; + + @Autowired + private ProfileService profileService; + + @Value("${mioverify.admin.default-username:admin}") + private String defaultUsername; + + @Value("${mioverify.admin.default-password:admin123}") + private String defaultPassword; + + @Override + public AdminUser login(String username, String password) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AdminUser::getUsername, username) + .eq(AdminUser::getEnabled, true); + + AdminUser admin = getOne(wrapper); + if (admin != null && passwordUtil.verifyPassword(password, admin.getPassword())) { + // Update last login time + admin.setLastLoginAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + updateById(admin); + return admin; + } + return null; + } + + @Override + public void initializeDefaultAdmin() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AdminUser::getUsername, defaultUsername); + + if (getOne(wrapper) == null) { + AdminUser defaultAdmin = new AdminUser() + .setId(Util.genUUID()) + .setUsername(defaultUsername) + .setPassword(passwordUtil.encryptPassword(defaultPassword)) + .setRole("ADMIN") + .setEnabled(true) + .setCreatedAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + + save(defaultAdmin); + log.info("Default admin user created: {}", defaultUsername); + } + } + + @Override + public Map getSystemStats() { + Map stats = new HashMap<>(); + stats.put("userCount", userService.count()); + stats.put("profileCount", profileService.count()); + stats.put("adminCount", count()); + stats.put("systemUptime", System.currentTimeMillis()); + return stats; + } + + @Override + public List getUsers(int page, int size) { + IPage userPage = new Page<>(page, size); + return userService.page(userPage).getRecords(); + } + + @Override + public long getUserCount() { + return userService.count(); + } + + @Override + public List getProfiles(int page, int size) { + IPage profilePage = new Page<>(page, size); + return profileService.page(profilePage).getRecords(); + } + + @Override + public long getProfileCount() { + return profileService.count(); + } + + @Override + public boolean deleteUser(String userId) { + // Also delete associated profiles + List userProfiles = profileService.getByUserId(userId); + for (Profile profile : userProfiles) { + profileService.removeById(profile.getId()); + } + return userService.removeById(userId); + } + + @Override + public boolean deleteProfile(String profileId) { + return profileService.removeById(profileId); + } +} diff --git a/src/main/java/org/miowing/mioverify/service/impl/HttpTextureSourceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/HttpTextureSourceImpl.java new file mode 100644 index 0000000..0e3906a --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/impl/HttpTextureSourceImpl.java @@ -0,0 +1,118 @@ +package org.miowing.mioverify.service.impl; + +import cn.hutool.http.HttpUtil; +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.exception.TextureNotFoundException; +import org.miowing.mioverify.service.TextureSourceService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.Duration; + +/** + * HTTP remote texture source implementation + */ +@Service +@ConditionalOnProperty(name = "mioverify.texture.sources.http.enabled", havingValue = "true") +@Slf4j +public class HttpTextureSourceImpl implements TextureSourceService { + + @Value("${mioverify.texture.sources.http.base-url}") + private String baseUrl; + + @Value("${mioverify.texture.sources.http.timeout:5000}") + private int timeout; + + @Value("${mioverify.texture.sources.http.cache-enabled:true}") + private boolean cacheEnabled; + + @Value("${mioverify.texture.sources.http.cache-duration:1h}") + private Duration cacheDuration; + + private final StringRedisTemplate redisTemplate; + private static final String CACHE_PREFIX = "texture:http:"; + + public HttpTextureSourceImpl(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public InputStream getTexture(String hash) { + // Check cache first + if (cacheEnabled) { + String cachedData = redisTemplate.opsForValue().get(CACHE_PREFIX + hash); + if (cachedData != null) { + byte[] data = java.util.Base64.getDecoder().decode(cachedData); + return new ByteArrayInputStream(data); + } + } + + // Try to fetch from HTTP + try { + String url = baseUrl + "/hash/" + hash; + byte[] data = HttpUtil.downloadBytes(url); + + // Cache the result + if (cacheEnabled && data != null) { + String encodedData = java.util.Base64.getEncoder().encodeToString(data); + redisTemplate.opsForValue().set(CACHE_PREFIX + hash, encodedData, cacheDuration); + } + + return new ByteArrayInputStream(data); + } catch (Exception e) { + log.warn("Failed to fetch texture from HTTP source: {}", e.getMessage()); + throw new TextureNotFoundException(); + } + } + + @Override + public InputStream getTexture(boolean skin, String hash) { + // For HTTP source, we use the same method as getTexture(hash) + // The remote server should handle the skin/cape distinction + return getTexture(hash); + } + + @Override + public void saveTexture(boolean skin, byte[] content, String hash) { + // HTTP source is read-only, cannot save textures + throw new UnsupportedOperationException("HTTP texture source is read-only"); + } + + @Override + public boolean deleteTexture(boolean skin, String hash) { + // HTTP source is read-only, cannot delete textures + return false; + } + + @Override + public boolean textureExists(String hash) { + try { + String url = baseUrl + "/hash/" + hash; + int responseCode = HttpUtil.createGet(url).timeout(timeout).execute().getStatus(); + return responseCode == 200; + } catch (Exception e) { + return false; + } + } + + @Override + public InputStream getDefaultSkin() { + try { + String url = baseUrl + "/skin/default"; + byte[] data = HttpUtil.downloadBytes(url); + return new ByteArrayInputStream(data); + } catch (Exception e) { + log.warn("Failed to fetch default skin from HTTP source: {}", e.getMessage()); + throw new TextureNotFoundException(); + } + } + + @Override + public String getSourceType() { + return "http"; + } +} diff --git a/src/main/java/org/miowing/mioverify/service/impl/LocalTextureSourceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/LocalTextureSourceImpl.java new file mode 100644 index 0000000..0dc0c1c --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/impl/LocalTextureSourceImpl.java @@ -0,0 +1,104 @@ +package org.miowing.mioverify.service.impl; + +import cn.hutool.core.io.FileUtil; +import org.miowing.mioverify.exception.TextureNotFoundException; +import org.miowing.mioverify.service.TextureSourceService; +import org.miowing.mioverify.util.DataUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.file.Path; + +/** + * Local file system texture source implementation + */ +@Service +@ConditionalOnProperty(name = "mioverify.texture.sources.local.enabled", havingValue = "true", matchIfMissing = true) +public class LocalTextureSourceImpl implements TextureSourceService { + + @Autowired + private DataUtil dataUtil; + + @Override + public InputStream getTexture(String hash) { + // Try skin first, then cape + Path skinPath = dataUtil.getTexturesPath().resolve("skin").resolve(hash); + File skinFile = skinPath.toFile(); + + try { + return new FileInputStream(skinFile); + } catch (FileNotFoundException e) { + Path capePath = dataUtil.getTexturesPath().resolve("cape").resolve(hash); + File capeFile = capePath.toFile(); + try { + return new FileInputStream(capeFile); + } catch (FileNotFoundException e0) { + throw new TextureNotFoundException(); + } + } + } + + @Override + public InputStream getTexture(boolean skin, String hash) { + Path texturePath = dataUtil.getTexturesPath() + .resolve(skin ? "skin" : "cape") + .resolve(hash); + File textureFile = texturePath.toFile(); + + try { + return new FileInputStream(textureFile); + } catch (FileNotFoundException e) { + throw new TextureNotFoundException(); + } + } + + @Override + public void saveTexture(boolean skin, byte[] content, String hash) { + Path texturePath = dataUtil.getTexturesPath() + .resolve(skin ? "skin" : "cape") + .resolve(hash); + + FileUtil.writeBytes(content, texturePath.toFile()); + } + + @Override + public boolean deleteTexture(boolean skin, String hash) { + Path texturePath = dataUtil.getTexturesPath() + .resolve(skin ? "skin" : "cape") + .resolve(hash); + File textureFile = texturePath.toFile(); + + if (textureFile.exists()) { + return textureFile.delete(); + } + return false; + } + + @Override + public boolean textureExists(String hash) { + Path skinPath = dataUtil.getTexturesPath().resolve("skin").resolve(hash); + Path capePath = dataUtil.getTexturesPath().resolve("cape").resolve(hash); + + return skinPath.toFile().exists() || capePath.toFile().exists(); + } + + @Override + public InputStream getDefaultSkin() { + File defaultSkin = dataUtil.getDefSkinLoc().toFile(); + try { + return new FileInputStream(defaultSkin); + } catch (FileNotFoundException e) { + throw new TextureNotFoundException(); + } + } + + @Override + public String getSourceType() { + return "local"; + } +} diff --git a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java index 642934a..e842ff8 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java @@ -1,5 +1,6 @@ package org.miowing.mioverify.service.impl; +import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.service.RedisService; import org.miowing.mioverify.util.DataUtil; import org.miowing.mioverify.util.SessionUtil; @@ -11,6 +12,7 @@ import java.util.Set; @Service +@Slf4j public class RedisServiceImpl implements RedisService { @Autowired private StringRedisTemplate redisTemplate; @@ -18,39 +20,60 @@ public class RedisServiceImpl implements RedisService { private DataUtil dataUtil; @Override public void saveToken(String token, String userId) { - redisTemplate.opsForValue().set(TokenUtil.TOKEN_PREF + token, userId); - redisTemplate.opsForValue().set(TokenUtil.TMARK_PREF + token, "", dataUtil.getTokenInvalid()); - redisTemplate.opsForHash().put(TokenUtil.USERID_PREF + userId, token, ""); + try { + redisTemplate.opsForValue().set(TokenUtil.TOKEN_PREF + token, userId); + redisTemplate.opsForValue().set(TokenUtil.TMARK_PREF + token, "", dataUtil.getTokenInvalid()); + redisTemplate.opsForHash().put(TokenUtil.USERID_PREF + userId, token, ""); + } catch (Exception e) { + log.warn("Failed to save token to Redis: " + e.getMessage()); + } } @Override public boolean checkToken(String token) { - return Boolean.TRUE.equals(redisTemplate.hasKey(TokenUtil.TOKEN_PREF + token)); + try { + return Boolean.TRUE.equals(redisTemplate.hasKey(TokenUtil.TOKEN_PREF + token)); + } catch (Exception e) { + log.warn("Failed to check token in Redis: " + e.getMessage()); + return false; + } } @Override public void removeToken(String token) { - String userId = redisTemplate.opsForValue().get(TokenUtil.TOKEN_PREF + token); - redisTemplate.delete(TokenUtil.TMARK_PREF + token); - redisTemplate.delete(TokenUtil.TOKEN_PREF + token); - HashOperations hops = redisTemplate.opsForHash(); - String userIdP = TokenUtil.USERID_PREF + userId; - if (hops.size(userIdP) < 2) { - redisTemplate.delete(userIdP); - } else { - hops.delete(userIdP, token); + try { + String userId = redisTemplate.opsForValue().get(TokenUtil.TOKEN_PREF + token); + redisTemplate.delete(TokenUtil.TMARK_PREF + token); + redisTemplate.delete(TokenUtil.TOKEN_PREF + token); + HashOperations hops = redisTemplate.opsForHash(); + String userIdP = TokenUtil.USERID_PREF + userId; + if (hops.size(userIdP) < 2) { + redisTemplate.delete(userIdP); + } else { + hops.delete(userIdP, token); + } + } catch (Exception e) { + log.warn("Failed to remove token from Redis: " + e.getMessage()); } } @Override public void clearToken(String userId) { - String userIdP = TokenUtil.USERID_PREF + userId; - Set keys = redisTemplate.opsForHash().keys(userIdP); - for (Object key : keys) { - redisTemplate.delete(TokenUtil.TMARK_PREF + key); - redisTemplate.delete(TokenUtil.TOKEN_PREF + key); + try { + String userIdP = TokenUtil.USERID_PREF + userId; + Set keys = redisTemplate.opsForHash().keys(userIdP); + for (Object key : keys) { + redisTemplate.delete(TokenUtil.TMARK_PREF + key); + redisTemplate.delete(TokenUtil.TOKEN_PREF + key); + } + redisTemplate.delete(userIdP); + } catch (Exception e) { + log.warn("Failed to clear tokens from Redis: " + e.getMessage()); } - redisTemplate.delete(userIdP); } @Override public void saveSession(String serverId, String token) { - redisTemplate.opsForValue().set(SessionUtil.SESSION_PREF + serverId, token, dataUtil.getSessionExpire()); + try { + redisTemplate.opsForValue().set(SessionUtil.SESSION_PREF + serverId, token, dataUtil.getSessionExpire()); + } catch (Exception e) { + log.warn("Failed to save session to Redis: " + e.getMessage()); + } } } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java index 75e135f..10fbab2 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java @@ -6,25 +6,49 @@ import org.miowing.mioverify.exception.LoginFailedException; import org.miowing.mioverify.pojo.User; import org.miowing.mioverify.service.UserService; +import org.miowing.mioverify.util.PasswordUtil; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; @Service public class UserServiceImpl extends ServiceImpl implements UserService { + + @Autowired + private PasswordUtil passwordUtil; + @Override public User getLogin(String username, String password) { return getLogin(username, password, false); } + @Override public User getLogin(String username, String password, boolean exception) { LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); - lqw.eq(User::getUsername, username).eq(User::getPassword, password); + lqw.eq(User::getUsername, username); User user = getOne(lqw); + + if (user != null) { + // Check if password is encrypted or plain text + if (passwordUtil.isPasswordEncrypted(user.getPassword())) { + // Verify encrypted password + if (!passwordUtil.verifyPassword(password, user.getPassword())) { + user = null; + } + } else { + // Legacy plain text password comparison + if (!password.equals(user.getPassword())) { + user = null; + } + } + } + if (user == null && exception) { throw new LoginFailedException(); } return user; } + @Override public @Nullable User getLoginNoPwd(String username) { LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); diff --git a/src/main/java/org/miowing/mioverify/util/ApiDocumentationUtil.java b/src/main/java/org/miowing/mioverify/util/ApiDocumentationUtil.java new file mode 100644 index 0000000..0856b2c --- /dev/null +++ b/src/main/java/org/miowing/mioverify/util/ApiDocumentationUtil.java @@ -0,0 +1,231 @@ +package org.miowing.mioverify.util; + +import org.miowing.mioverify.pojo.ApiMetadata; +import org.miowing.mioverify.pojo.ServerMeta; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * API documentation utility for generating API metadata + */ +@Component +public class ApiDocumentationUtil { + + @Autowired + private Util util; + + @Autowired + private DataUtil dataUtil; + + /** + * Generate complete API metadata + */ + public ApiMetadata generateApiMetadata() { + ServerMeta serverMeta = util.getServerMeta(); + String baseUrl = util.getServerURL(); + + ApiMetadata metadata = new ApiMetadata() + .setVersion("1.3.0") + .setServerName(serverMeta.getMeta().getServerName()) + .setImplementationName(serverMeta.getMeta().getImplementationName()) + .setImplementationVersion(serverMeta.getMeta().getImplementationVersion()) + .setBaseUrl(baseUrl) + .setEndpoints(generateEndpoints()) + .setFeatures(generateFeatures()) + .setClientConfig(generateClientConfig(baseUrl, serverMeta)); + + return metadata; + } + + /** + * Generate API endpoints documentation + */ + private List generateEndpoints() { + List endpoints = new ArrayList<>(); + + // Authentication endpoints + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("authenticate") + .setPath("/authserver/authenticate") + .setMethod("POST") + .setDescription("用户登录认证") + .setParameters(List.of("username", "password", "clientToken", "requestUser")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("refresh") + .setPath("/authserver/refresh") + .setMethod("POST") + .setDescription("刷新访问令牌") + .setParameters(List.of("accessToken", "clientToken", "requestUser")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("validate") + .setPath("/authserver/validate") + .setMethod("POST") + .setDescription("验证访问令牌") + .setParameters(List.of("accessToken", "clientToken")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("invalidate") + .setPath("/authserver/invalidate") + .setMethod("POST") + .setDescription("注销访问令牌") + .setParameters(List.of("accessToken", "clientToken")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("signout") + .setPath("/authserver/signout") + .setMethod("POST") + .setDescription("用户登出") + .setParameters(List.of("username", "password")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + // Session endpoints + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("join") + .setPath("/sessionserver/session/minecraft/join") + .setMethod("POST") + .setDescription("加入游戏服务器") + .setParameters(List.of("accessToken", "selectedProfile", "serverId")) + .setResponseType("application/json") + .setRequiresAuth(true)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("hasJoined") + .setPath("/sessionserver/session/minecraft/hasJoined") + .setMethod("GET") + .setDescription("验证玩家是否已加入服务器") + .setParameters(List.of("username", "serverId")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("profile") + .setPath("/sessionserver/session/minecraft/profile/{uuid}") + .setMethod("GET") + .setDescription("获取角色信息") + .setParameters(List.of("uuid", "unsigned")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + // API endpoints + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("profiles") + .setPath("/api/profiles/minecraft") + .setMethod("POST") + .setDescription("批量获取角色信息") + .setParameters(List.of("names")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + // Texture endpoints + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("uploadTexture") + .setPath("/api/user/profile/{uuid}/{textureType}") + .setMethod("PUT") + .setDescription("上传材质") + .setParameters(List.of("uuid", "textureType", "model", "file")) + .setResponseType("application/json") + .setRequiresAuth(true)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("deleteTexture") + .setPath("/api/user/profile/{uuid}/{textureType}") + .setMethod("DELETE") + .setDescription("删除材质") + .setParameters(List.of("uuid", "textureType")) + .setResponseType("application/json") + .setRequiresAuth(true)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("getTexture") + .setPath("/texture/hash/{hash}") + .setMethod("GET") + .setDescription("获取材质文件") + .setParameters(List.of("hash")) + .setResponseType("image/png") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("getDefaultSkin") + .setPath("/texture/skin/default") + .setMethod("GET") + .setDescription("获取默认皮肤") + .setParameters(List.of()) + .setResponseType("image/png") + .setRequiresAuth(false)); + + // External endpoints + if (dataUtil.isAllowRegister()) { + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("registerUser") + .setPath("/extern/register/user") + .setMethod("POST") + .setDescription("注册用户") + .setParameters(List.of("username", "password", "preferredLang", "key")) + .setResponseType("application/json") + .setRequiresAuth(false)); + + endpoints.add(new ApiMetadata.ApiEndpoint() + .setName("registerProfile") + .setPath("/extern/register/profile") + .setMethod("POST") + .setDescription("注册角色") + .setParameters(List.of("profileName", "username", "password", "skinUploadAllow", "capeUploadAllow", "key")) + .setResponseType("application/json") + .setRequiresAuth(false)); + } + + return endpoints; + } + + /** + * Generate features map + */ + private Map generateFeatures() { + Map features = new HashMap<>(); + features.put("registration", dataUtil.isAllowRegister()); + features.put("userRegistration", dataUtil.isAllowRegUser()); + features.put("profileRegistration", dataUtil.isAllowRegProfile()); + features.put("multiProfileName", dataUtil.isMultiProfileName()); + features.put("textureUpload", true); + features.put("textureDelete", true); + features.put("adminInterface", true); + features.put("dynamicTextureSources", true); + features.put("passwordEncryption", true); + return features; + } + + /** + * Generate client configuration + */ + private ApiMetadata.ClientConfiguration generateClientConfig(String baseUrl, ServerMeta serverMeta) { + Map clientFeatures = new HashMap<>(); + clientFeatures.put("non_email_login", serverMeta.getMeta().getFeatureNonEmailLogin()); + clientFeatures.put("legacy_skin_api", serverMeta.getMeta().getFeatureLegacySkinApi()); + clientFeatures.put("no_mojang_namespace", serverMeta.getMeta().getFeatureNoMojangNamespace()); + + return new ApiMetadata.ClientConfiguration() + .setAuthServerUrl(baseUrl + "/authserver") + .setSessionServerUrl(baseUrl + "/sessionserver") + .setApiServerUrl(baseUrl + "/api") + .setTextureServerUrl(baseUrl + "/texture") + .setSkinDomains(serverMeta.getSkinDomains()) + .setSignaturePublicKey(serverMeta.getSignaturePublicKey()) + .setFeatures(clientFeatures); + } +} diff --git a/src/main/java/org/miowing/mioverify/util/PasswordUtil.java b/src/main/java/org/miowing/mioverify/util/PasswordUtil.java new file mode 100644 index 0000000..2aef30a --- /dev/null +++ b/src/main/java/org/miowing/mioverify/util/PasswordUtil.java @@ -0,0 +1,43 @@ +package org.miowing.mioverify.util; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Password encryption utility + */ +@Component +public class PasswordUtil { + + @Autowired + private PasswordEncoder passwordEncoder; + + /** + * Encrypt password using BCrypt + * @param rawPassword raw password + * @return encrypted password + */ + public String encryptPassword(String rawPassword) { + return passwordEncoder.encode(rawPassword); + } + + /** + * Verify password + * @param rawPassword raw password + * @param encodedPassword encrypted password + * @return true if password matches + */ + public boolean verifyPassword(String rawPassword, String encodedPassword) { + return passwordEncoder.matches(rawPassword, encodedPassword); + } + + /** + * Check if password is already encrypted (BCrypt format) + * @param password password to check + * @return true if password is encrypted + */ + public boolean isPasswordEncrypted(String password) { + return password != null && password.startsWith("$2a$") && password.length() == 60; + } +} diff --git a/src/main/java/org/miowing/mioverify/util/StorageUtil.java b/src/main/java/org/miowing/mioverify/util/StorageUtil.java index 60ebbff..d17bcbc 100644 --- a/src/main/java/org/miowing/mioverify/util/StorageUtil.java +++ b/src/main/java/org/miowing/mioverify/util/StorageUtil.java @@ -1,63 +1,39 @@ package org.miowing.mioverify.util; -import cn.hutool.core.io.FileUtil; -import org.miowing.mioverify.exception.TextureNotFoundException; +import org.miowing.mioverify.service.TextureSourceManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.InputStream; -import java.nio.file.Path; /** - * TODO Promote the extensive operations - * Storage manager of textures (temporarily implementation). + * Storage manager of textures with dynamic source support */ @Component public class StorageUtil { + @Autowired - DataUtil dataUtil; - public void saveTexture(boolean skin, byte[] c, String sha) { - FileUtil.writeBytes( - c, - dataUtil.getTexturesPath() - .resolve(skin ? "skin" : "cape") - .resolve(sha) - .toFile() - ); + private TextureSourceManager textureSourceManager; + public void saveTexture(boolean skin, byte[] content, String hash) { + textureSourceManager.saveTexture(skin, content, hash); + } + + public boolean deleteTexture(boolean skin, String hash) { + return textureSourceManager.deleteTexture(skin, hash); + } + + public InputStream getTexture(boolean skin, String hash) { + return textureSourceManager.getTexture(skin, hash); } - public boolean deleteTexture(boolean skin, String sha) { - Path t = dataUtil.getTexturesPath().resolve(skin ? "skin" : "cape"); - File f = t.resolve(sha).toFile(); - System.out.println("start delete"); - if (f.exists()) { - return f.delete(); - } - return false; + + public InputStream getTexture(String hash) { + return textureSourceManager.getTexture(hash); } - public InputStream getTexture(boolean skin, String sha) throws TextureNotFoundException { - Path t = dataUtil.getTexturesPath().resolve(skin ? "skin" : "cape"); - File f = t.resolve(sha).toFile(); - try { - return new FileInputStream(f); - } catch (FileNotFoundException e) { - throw new TextureNotFoundException(); - } + + public InputStream getDefaultSkin() { + return textureSourceManager.getDefaultSkin(); } - public InputStream getTexture(String sha) { - Path t = dataUtil.getTexturesPath().resolve("skin"); - File f = t.resolve(sha).toFile(); - try { - return new FileInputStream(f); - } catch (FileNotFoundException e) { - t = t.resolveSibling("cape"); - f = t.resolve(sha).toFile(); - try { - return new FileInputStream(f); - } catch (FileNotFoundException e0) { - throw new TextureNotFoundException(); - } - } + + public boolean textureExists(String hash) { + return textureSourceManager.textureExists(hash); } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 190a855..e2aecd3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -60,6 +60,18 @@ mioverify: key: admin123098 # 是否允许重复角色名(仅发生在注册和修改) multi-profile-name: true + # 管理员配置 + admin: + # 是否启用管理界面 + enabled: true + # 默认管理员账号 + default-username: admin + # 默认管理员密码 (首次启动时会自动加密) + default-password: admin123 + # 管理界面访问路径 + base-path: /admin + # 会话超时时间 + session-timeout: 30m token: # 给角色(Profile)签名的文本 signature: 'abcd273nsi179a' @@ -84,6 +96,28 @@ mioverify: storage-loc: textures # 默认皮肤存储位置 default-skin-loc: textures/skin/default.png + # 材质源配置 + sources: + # 主要材质源类型: local, http, cloud + primary: local + # 本地存储配置 + local: + enabled: true + base-path: textures + # HTTP远程材质源配置 + http: + enabled: false + base-url: https://example.com/textures + timeout: 5000 + # 是否启用缓存 + cache-enabled: true + cache-duration: 1h + # 云存储配置 (预留) + cloud: + enabled: false + provider: aws-s3 + bucket: mioverify-textures + region: us-east-1 # 服务器元数据,详见Yggdrasil API props: meta: diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 21c9afc..2d2d9d1 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1,19 +1,32 @@ -CREATE TABLE IF NOT EXISTS `profiles` ( - `id` varchar(64) NOT NULL, - `name` varchar(255) NOT NULL, - `bind_user` varchar(64) NOT NULL, - `skin_up_allow` bit(1) NOT NULL DEFAULT 0, - `cape_up_allow` bit(1) NOT NULL DEFAULT 0, - `skin_hash` varchar(255) NULL DEFAULT NULL, - `cape_hash` varchar(255) NULL DEFAULT NULL, - `skin_slim` bit(1) NULL DEFAULT 0, - PRIMARY KEY (`id`) +CREATE TABLE IF NOT EXISTS profiles ( + id varchar(64) NOT NULL, + name varchar(255) NOT NULL, + bind_user varchar(64) NOT NULL, + skin_up_allow integer NOT NULL DEFAULT 0, + cape_up_allow integer NOT NULL DEFAULT 0, + skin_hash varchar(255) DEFAULT NULL, + cape_hash varchar(255) DEFAULT NULL, + skin_slim integer DEFAULT 0, + PRIMARY KEY (id) ); -CREATE TABLE IF NOT EXISTS `users` ( - `id` varchar(64) NOT NULL, - `username` varchar(255) NOT NULL, - `password` varchar(255) NOT NULL, - `preferred_lang` varchar(8) NULL DEFAULT NULL, - PRIMARY KEY (`id`) -); \ No newline at end of file +CREATE TABLE IF NOT EXISTS users ( + id varchar(64) NOT NULL, + username varchar(255) NOT NULL, + password varchar(255) NOT NULL, + preferred_lang varchar(8) DEFAULT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS admin_users ( + id varchar(64) NOT NULL, + username varchar(255) NOT NULL, + password varchar(255) NOT NULL, + role varchar(50) NOT NULL DEFAULT 'ADMIN', + enabled integer NOT NULL DEFAULT 1, + created_at varchar(50) DEFAULT NULL, + last_login_at varchar(50) DEFAULT NULL, + PRIMARY KEY (id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_admin_username ON admin_users (username); \ No newline at end of file diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 0000000..28f7d26 --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,188 @@ + + + + + + MioVerify 管理后台 - 仪表板 + + + +
+

MioVerify 管理后台

+ +
+ +
+
+
+

总用户数

+
0
+
+ +
+

总角色数

+
0
+
+ +
+

管理员数

+
0
+
+ +
+

系统运行时间

+
计算中...
+
+
+ +
+

快速操作

+ +
+
+ + + + diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 0000000..f89f84e --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,167 @@ + + + + + + MioVerify 管理后台 - 登录 + + + + + + + +