From 59f5e1aeedd104f6901d24123c8723cf8565e92b Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Fri, 20 Feb 2026 08:24:44 +0800 Subject: [PATCH 01/12] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=BE=93=E5=87=BA=20javadoc:=20=E4=BC=98=E5=8C=96=20J?= =?UTF-8?q?avadoc=20=E4=B8=BB=E9=A1=B5=E5=92=8C=E5=8C=85=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- overview.html | 17 +++++++++++++++++ .../mioverify/controller/package-info.java | 4 ++++ .../org/miowing/mioverify/dao/package-info.java | 4 ++++ .../org/miowing/mioverify/package-info.java | 5 +++++ .../miowing/mioverify/pojo/package-info.java | 4 ++++ .../miowing/mioverify/service/package-info.java | 4 ++++ src/main/resources/logback-spring.xml | 4 ++-- 7 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 overview.html create mode 100644 src/main/java/org/miowing/mioverify/controller/package-info.java create mode 100644 src/main/java/org/miowing/mioverify/dao/package-info.java create mode 100644 src/main/java/org/miowing/mioverify/package-info.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/package-info.java create mode 100644 src/main/java/org/miowing/mioverify/service/package-info.java diff --git a/overview.html b/overview.html new file mode 100644 index 0000000..7fa2b12 --- /dev/null +++ b/overview.html @@ -0,0 +1,17 @@ + + + + MioVerify 项目概述 + + + +

MioVerify

+

版本: v1.2.0-BETA (Java 17)
+ 维护者: pingguomc (基于 FuziharaYukina 的开发)
+ Github: 仓库地址

+ +

MioVerify 是一个依据 Yggdrasil 服务端技术规范 实现的 Minecraft 身份验证服务器。本分支专注于支持 OAuth 2 登录系统、修复安全漏洞及性能优化。

+ +@author Fuzihara Yukina (原仓库), pingguomc (当前维护者) + + \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/controller/package-info.java b/src/main/java/org/miowing/mioverify/controller/package-info.java new file mode 100644 index 0000000..1f6799d --- /dev/null +++ b/src/main/java/org/miowing/mioverify/controller/package-info.java @@ -0,0 +1,4 @@ +/** + * 控制层 + */ +package org.miowing.mioverify.controller; \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/dao/package-info.java b/src/main/java/org/miowing/mioverify/dao/package-info.java new file mode 100644 index 0000000..97d15c3 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/dao/package-info.java @@ -0,0 +1,4 @@ +/** + * 数据访问层 + */ +package org.miowing.mioverify.dao; \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/package-info.java b/src/main/java/org/miowing/mioverify/package-info.java new file mode 100644 index 0000000..a9855ff --- /dev/null +++ b/src/main/java/org/miowing/mioverify/package-info.java @@ -0,0 +1,5 @@ +/** + * MioVerify 的顶级程序包,包含了大多数代码。
+ * 反转域名由最初的作者 FuziharaYukina 设置 + */ +package org.miowing.mioverify; \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/pojo/package-info.java b/src/main/java/org/miowing/mioverify/pojo/package-info.java new file mode 100644 index 0000000..6c99e88 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/package-info.java @@ -0,0 +1,4 @@ +/** + * 数据层 + */ +package org.miowing.mioverify.pojo; \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/service/package-info.java b/src/main/java/org/miowing/mioverify/service/package-info.java new file mode 100644 index 0000000..0fb6da2 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/package-info.java @@ -0,0 +1,4 @@ +/** + * 业务层 + */ +package org.miowing.mioverify.service; \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 3cf6644..6ebb26e 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -7,7 +7,7 @@ - [%d{MM-dd HH:mm:ss}] [%thread] %-5level - [%logger{36}] : %msg%n + [%d{MM-dd HH:mm:ss.SSS}] [%thread] %-5level - [%logger{36}] : %msg%n @@ -25,7 +25,7 @@ 2GB - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level - [%logger{36}] : %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - [%logger{36}] : %msg%n From 65a15fe7ae9f8d66da64bd31de2e5202300fdb9a Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:48:25 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Cors=20?= =?UTF-8?q?=E8=B7=A8=E7=AB=99=E6=94=AF=E6=8C=81=20wip:=20Oauth=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++++++ .../mioverify/MioVerifyApplication.java | 1 + .../miowing/mioverify/config/CorsConfig.java | 49 ++++++++++++ .../controller/ExceptionProcessor.java | 16 ++++ .../controller/ExternController.java | 1 + .../mioverify/controller/OAuthController.java | 67 +++++++++++++++++ .../FeatureNotSupportedException.java | 3 + .../java/org/miowing/mioverify/pojo/User.java | 6 +- .../pojo/request/OauthUserRegisterReq.java | 26 +++++++ .../pojo/request/UserRegisterReq.java | 1 + .../mioverify/service/OAuthService.java | 27 +++++++ .../org/miowing/mioverify/util/DataUtil.java | 17 +++++ src/main/resources/application.yml | 74 ++++++++++++++++++- src/main/resources/db/schema.sql | 12 ++- 14 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/miowing/mioverify/config/CorsConfig.java create mode 100644 src/main/java/org/miowing/mioverify/controller/OAuthController.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java create mode 100644 src/main/java/org/miowing/mioverify/service/OAuthService.java diff --git a/README.md b/README.md index bcfd775..a9960e8 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,27 @@ Yggdrasil API是一套规范,定义了如何实现身份验证。而MioVerify 在项目目录下输入命令行启动对应jar即可。 +### 注册 Github Oauth 应用 + +> [!important] +> 你必须预先注册,否则无法使用 Github Oauth + +登录 GitHub → 右上角头像 → Settings → Developer settings → OAuth Apps → New OAuth App。 + +填写应用信息: + +Application name: 你的应用名称(例如 "MyBlog") + +Homepage URL: 你的应用首页地址(如 https://yourdomain.com) + +Authorization callback URL: 必须填写你部署实例的回调地址,格式如 https://yourdomain.com/oauth/github/callback(注意与 redirect-uri 一致)。 + +注册成功后,复制 Client ID。 + +点击 Generate a new client secret,生成并复制 Client Secret(页面关闭后不再显示)。 + +将这两个值填入部署环境的环境变量中。 + ## 扩展API 不同于 Yggdrasil API,MioVerify 提供了一套扩展的API,方便调用实现注册等功能,但还需要前端或者客户端继续实现可视化操作。 diff --git a/src/main/java/org/miowing/mioverify/MioVerifyApplication.java b/src/main/java/org/miowing/mioverify/MioVerifyApplication.java index 62ecb7d..2885edf 100644 --- a/src/main/java/org/miowing/mioverify/MioVerifyApplication.java +++ b/src/main/java/org/miowing/mioverify/MioVerifyApplication.java @@ -7,5 +7,6 @@ public class MioVerifyApplication { public static void main(String[] args) { SpringApplication.run(MioVerifyApplication.class, args); + // } } diff --git a/src/main/java/org/miowing/mioverify/config/CorsConfig.java b/src/main/java/org/miowing/mioverify/config/CorsConfig.java new file mode 100644 index 0000000..48be420 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/config/CorsConfig.java @@ -0,0 +1,49 @@ +package org.miowing.mioverify.config; + +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.util.DataUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Collections; + +/** + * 跨域配置,通常在生产环境中启用 + */ +@Slf4j +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter(DataUtil dataUtil) { + // 未启用跨域,返回空过滤器 + if (!dataUtil.isCorsEnabled()) { + return new CorsFilter(new UrlBasedCorsConfigurationSource()); + } + + CorsConfiguration config = new CorsConfiguration(); + + String allowedOrigin = dataUtil.getCorsAllowedOrigin(); + + // 检查配置是否为空 + if (allowedOrigin == null || allowedOrigin.trim().isEmpty()) { + log.warn("CORS is enabled, but allowed origin is null or empty"); + return new CorsFilter(new UrlBasedCorsConfigurationSource()); + } + + // 设置允许域 + config.setAllowedOriginPatterns(Collections.singletonList(allowedOrigin.trim())); + + config.addAllowedMethod("*"); + config.addAllowedHeader("*"); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} diff --git a/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java b/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java index 534482e..1126466 100644 --- a/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java +++ b/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java @@ -7,8 +7,13 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + *

全局异常处理

+ * 异常分类捕获 + 标准化响应 + */ @RestControllerAdvice public class ExceptionProcessor { + @ExceptionHandler(LoginFailedException.class) public ResponseEntity handleLoginFailed() { return new ResponseEntity<>( @@ -17,6 +22,7 @@ public ResponseEntity handleLoginFailed() { HttpStatus.FORBIDDEN ); } + @ExceptionHandler(NoProfileException.class) public ResponseEntity handleNoProfile() { return new ResponseEntity<>( @@ -25,6 +31,7 @@ public ResponseEntity handleNoProfile() { HttpStatus.FORBIDDEN ); } + @ExceptionHandler(InvalidTokenException.class) public ResponseEntity handleInvalidToken() { return new ResponseEntity<>( @@ -33,18 +40,25 @@ public ResponseEntity handleInvalidToken() { HttpStatus.FORBIDDEN ); } + @ExceptionHandler({ProfileNotFoundException.class, UserMismatchException.class, InvalidSessionException.class}) public ResponseEntity handleNoContent() { return new ResponseEntity<>(HttpStatus.NO_CONTENT); + // } + @ExceptionHandler(UnauthorizedException.class) public ResponseEntity handleUnauthorized() { return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + // } + @ExceptionHandler(TextureNotFoundException.class) public ResponseEntity handleNotFound() { return new ResponseEntity<>(HttpStatus.NOT_FOUND); + // } + @ExceptionHandler( { AttackDefenseException.class, @@ -58,5 +72,7 @@ public ResponseEntity handleNotFound() { ) public ResponseEntity handleForbidden() { return new ResponseEntity<>(HttpStatus.FORBIDDEN); + // } + } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/controller/ExternController.java b/src/main/java/org/miowing/mioverify/controller/ExternController.java index 89603d4..29cea9a 100644 --- a/src/main/java/org/miowing/mioverify/controller/ExternController.java +++ b/src/main/java/org/miowing/mioverify/controller/ExternController.java @@ -33,6 +33,7 @@ public class ExternController { private ProfileService profileService; @Autowired private DataUtil dataUtil; + @Deprecated(since = "1.4.0") @PostMapping("/register/user") public ResponseEntity registerUser(@RequestBody UserRegisterReq req) { if (!dataUtil.isAllowRegister()) { diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java new file mode 100644 index 0000000..1bcce99 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java @@ -0,0 +1,67 @@ +package org.miowing.mioverify.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.exception.FeatureNotSupportedException; +import org.miowing.mioverify.service.ProfileService; +import org.miowing.mioverify.service.RedisService; +import org.miowing.mioverify.service.UserService; +import org.miowing.mioverify.util.DataUtil; +import org.miowing.mioverify.util.TokenUtil; +import org.miowing.mioverify.util.Util; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * OAuth 2.0 / OIDC 相关控制器 + * 所有端点均以 /oauth 开头,处理第三方登录、绑定、解绑等操作 + */ +@Slf4j +@RestController +@RequestMapping("/oauth") +public class OAuthController { + + @Autowired + private UserService userService; + + @Autowired + private ProfileService profileService; + + @Autowired + private RedisService redisService; + + @Autowired + private DataUtil dataUtil; + + @Autowired + private TokenUtil tokenUtil; + + @Autowired + private Util util; + + + /** + * 跳转到 OAuth 提供商授权页 + * 可选参数 bind=true 表示此次授权用于绑定到当前已登录账号 + */ + @GetMapping("/authorize/{provider}") + public ResponseEntity authorize( + @PathVariable String provider, + @RequestParam(value = "bind", defaultValue = "false") boolean bind, + HttpServletRequest request) { + if (!dataUtil.isOAuthMode()) { + throw new FeatureNotSupportedException(); + } + // 构建授权 URL,将 bind 状态和当前登录用户信息存入 state TODO +// String url = OAuthService.buildAuthorizationUrl(provider, bind, request); + HttpHeaders headers = new HttpHeaders(); +// headers.setLocation(URI.create(url)); + return new ResponseEntity<>(headers, HttpStatus.FOUND); + } + + + +} diff --git a/src/main/java/org/miowing/mioverify/exception/FeatureNotSupportedException.java b/src/main/java/org/miowing/mioverify/exception/FeatureNotSupportedException.java index 5fc9a5a..db3e4b0 100644 --- a/src/main/java/org/miowing/mioverify/exception/FeatureNotSupportedException.java +++ b/src/main/java/org/miowing/mioverify/exception/FeatureNotSupportedException.java @@ -1,4 +1,7 @@ package org.miowing.mioverify.exception; +/** + * 功能不支持异常 + */ public class FeatureNotSupportedException extends MioVerifyException { } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/pojo/User.java b/src/main/java/org/miowing/mioverify/pojo/User.java index 2d49590..2c80c76 100644 --- a/src/main/java/org/miowing/mioverify/pojo/User.java +++ b/src/main/java/org/miowing/mioverify/pojo/User.java @@ -13,6 +13,10 @@ public class User { @TableId private String id; private String username; - private String password; + private @Nullable String password; private @Nullable String preferredLang; + private @Nullable String microsoftId; + private @Nullable String githubId; + private @Nullable String mcjpgId; + private @Nullable String customId; } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java b/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java new file mode 100644 index 0000000..9d24416 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java @@ -0,0 +1,26 @@ +package org.miowing.mioverify.pojo.request; + +import lombok.Data; +import org.springframework.lang.Nullable; + +@Data +public class OauthUserRegisterReq { + + private String username; + + private @Nullable String password; // 密码可为空 + + private String preferredLang = "zh_CN"; + + private String key; + + // OAuth相关字段 + + private @Nullable String microsoftId; + + private @Nullable String githubId; + + private @Nullable String mcjpgId; + + private @Nullable String customId; +} diff --git a/src/main/java/org/miowing/mioverify/pojo/request/UserRegisterReq.java b/src/main/java/org/miowing/mioverify/pojo/request/UserRegisterReq.java index 9a8814d..6875184 100644 --- a/src/main/java/org/miowing/mioverify/pojo/request/UserRegisterReq.java +++ b/src/main/java/org/miowing/mioverify/pojo/request/UserRegisterReq.java @@ -3,6 +3,7 @@ import lombok.Data; @Data +@Deprecated(since = "1.4.0") public class UserRegisterReq { private String username; private String password; diff --git a/src/main/java/org/miowing/mioverify/service/OAuthService.java b/src/main/java/org/miowing/mioverify/service/OAuthService.java new file mode 100644 index 0000000..d166d7e --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/OAuthService.java @@ -0,0 +1,27 @@ +package org.miowing.mioverify.service; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.List; + +public interface OAuthService { + /** + * 获取启用的OAuth提供商列表(内部标识) + */ + List getEnabledProviders(); + +// /** TODO +// * 获取所有支持的OAuth提供商详情 +// */ +// List getSupportedProviders(); + + /** + * 构建授权跳转URL + */ + String buildAuthorizationUrl(String provider, boolean bind, HttpServletRequest request); + +// /** TODO +// * 用授权码换取用户信息 +// */ +// OAuthUserInfo exchangeCodeAndGetUserInfo(String provider, String code); +} diff --git a/src/main/java/org/miowing/mioverify/util/DataUtil.java b/src/main/java/org/miowing/mioverify/util/DataUtil.java index d3710d2..7399fbe 100644 --- a/src/main/java/org/miowing/mioverify/util/DataUtil.java +++ b/src/main/java/org/miowing/mioverify/util/DataUtil.java @@ -55,8 +55,25 @@ public class DataUtil implements InitializingBean { private boolean profileStrict; @Value("${mioverify.extern.multi-profile-name}") private boolean multiProfileName; + + //以下为跨域设置 + @Value("${mioverify.cors.enabled}") + private boolean corsEnabled; + @Value("${mioverify.cors.allowed-origin}") + private String corsAllowedOrigin; + + //以下内容为 Oauth 配置 + /** + * 是否启用 Oauth 模式 + */ + @Value("${mioverify.oauth.enabled}") + private boolean isOAuthMode; + + @Override public void afterPropertiesSet() throws Exception { log.info("Reading data from application file..."); + // } + } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 190a855..7797c8f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,7 +27,7 @@ spring: # 最大上传请求尺寸 max-request-size: 2MB profiles: - # 选择数据库,"test-sqlite" 或 "test-mysql" + # 选择数据库,"test-sqlite" 或 "test-mysql",这两个在下面配置 active: test-sqlite data: # 设置Redis配置 @@ -40,6 +40,74 @@ mybatis-plus: global-config: banner: false mioverify: + + # 跨域配置,生产环境中必须设置 + cors: + # 是否启用跨域配置(若在 CDN 中配置或不跨域,可以不启用) + enabled: false + # 允许跨域的域名(* 表示允许所有,但生产环境不建议。) + allowed-origin: "https://your-domain.com" + + # Oauth 配置项 + oauth: + # 是否启用 Oauth 用户注册,注意不启用则无法注册用户,也不能使用 Oauth 登录 + enabled: true + # OAuth 2 授权码状态有效期 + expire: 5m + microsoft: + enabled: false + # 使用 issuer 自动发现所有端点,这是最推荐的方式 + issuer: "https://login.microsoftonline.com/common/v2.0" + client-id: "自行填写客户端ID" + client-secret: "自行填写客户端密钥" + # 授权成功后的回调地址,GitHub 会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。 + redirect-uri: "http://localhost:8080/oauth/microsoft/callback" + + # OIDC 必须包含 openid,并请求基本信息和刷新令牌 + scope: "openid profile email offline_access" + + github: + enabled: false + # 以下三个内容需自行设置 + client-id: "你的GitHub客户端ID" + client-secret: "你的GitHub客户端密钥" + # 授权成功后的回调地址,GitHub 会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。 + redirect-uri: "http://localhost:8080/oauth/github/callback" + # GitHub 的授权端点 URL + authorization-url: "https://github.com/login/oauth/authorize" + # GitHub 的访问令牌端点 URL + token-url: "https://github.com/login/oauth/access_token" + # GitHub 的用户信息 API 端点 URL + user-info-url: "https://api.github.com/user" + # 向 GitHub 请求的权限范围 + scope: "user:email" + + mcjpg: + enabled: false + # 使用 issuer URL,自动发现所有端点 + issuer: "https://auth.your-domain.com/realms/your-realm" # OIDC 地址 + client-id: "{OIDC_CLIENT_ID}" + client-secret: "{OIDC_CLIENT_SECRET}" + redirect-uri: "http://localhost:8080/oauth/mcjpg/callback" # 回调地址 + # 2. 请求 openid scope,并附带 profile 和 email + scope: "openid profile email" + + custom: + enabled: false + # 以下所有内容需自行设置 + client-id: "客户端ID" + client-secret: "客户端密钥" + # 授权成功后的回调地址,会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。 + redirect-uri: "http://localhost:8080/oauth/custom/callback" + # 授权端点 URL + authorization-url: "https://your-auth-server.com/login/oauth/authorize" + # 访问令牌端点 URL + token-url: "https://your-auth-server.com/login/oauth/access_token" + # 用户信息 API 端点 URL + user-info-url: "https://your-auth-server.com/user" + # 请求的权限范围 + scope: "user:email" + # 扩展API配置项 extern: register: @@ -84,7 +152,7 @@ mioverify: storage-loc: textures # 默认皮肤存储位置 default-skin-loc: textures/skin/default.png - # 服务器元数据,详见Yggdrasil API + # 服务器元数据,详见Yggdrasil API props: meta: # 将会显示在启动器 @@ -92,7 +160,7 @@ mioverify: # 实现的名称 implementation-name: mioverify # 实现的版本 - implementation-version: '0.0.1' + implementation-version: '${project.version}' links: # 主页地址 home-page: https://www.baidu.com diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 21c9afc..ff3ba74 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -13,7 +13,15 @@ CREATE TABLE IF NOT EXISTS `profiles` ( CREATE TABLE IF NOT EXISTS `users` ( `id` varchar(64) NOT NULL, `username` varchar(255) NOT NULL, - `password` varchar(255) NOT NULL, + `password` varchar(255) NULL DEFAULT NULL, `preferred_lang` varchar(8) NULL DEFAULT NULL, - PRIMARY KEY (`id`) + `microsoft_id` VARCHAR(255) NULL, + `github_id` VARCHAR(255) NULL, + `mcjpg_id` VARCHAR(255) NULL, + `custom_id` VARCHAR(255) NULL, + PRIMARY KEY (`id`), + UNIQUE (`microsoft_id`), + UNIQUE (`github_id`), + UNIQUE (`mcjpg_id`), + UNIQUE (`custom_id`) ); \ No newline at end of file From bab54412d55042c93f0822fa831c5aedd4e60a47 Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:52:05 +0800 Subject: [PATCH 03/12] =?UTF-8?q?wip:=20Oauth=20=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 + .../miowing/mioverify/config/HttpConfig.java | 10 + .../mioverify/config/SecurityConfig.java | 208 ++++++++++++++++++ .../mioverify/config/package-info.java | 4 + .../controller/ExceptionProcessor.java | 6 + .../controller/ExternController.java | 1 + .../mioverify/controller/OAuthController.java | 157 +++++++++++-- .../ServerConfigurationException.java | 5 + .../org/miowing/mioverify/pojo/Profile.java | 3 + .../miowing/mioverify/pojo/ProfileShow.java | 18 ++ .../miowing/mioverify/pojo/ServerMeta.java | 4 + .../miowing/mioverify/pojo/TexturesShow.java | 21 ++ .../java/org/miowing/mioverify/pojo/User.java | 3 + .../org/miowing/mioverify/pojo/UserShow.java | 17 ++ .../mioverify/pojo/oauth/OAuthAuthReq.java | 22 ++ .../mioverify/pojo/oauth/OAuthBindReq.java | 19 ++ .../pojo/oauth/OAuthCallbackResp.java | 20 ++ .../pojo/oauth/OAuthProviderListResp.java | 5 + .../mioverify/pojo/oauth/OAuthState.java | 14 ++ .../mioverify/pojo/oauth/OAuthStatusResp.java | 23 ++ .../mioverify/pojo/oauth/OAuthUserInfo.java | 14 ++ .../OauthUserRegisterReq.java | 2 +- .../mioverify/pojo/oauth/package-info.java | 1 + .../mioverify/service/OAuthService.java | 33 +-- .../mioverify/service/RedisService.java | 20 ++ .../mioverify/service/UserService.java | 9 + .../service/impl/OAuthServiceImpl.java | 136 ++++++++++++ .../service/impl/RedisServiceImpl.java | 28 +++ .../service/impl/UserServiceImpl.java | 19 ++ .../org/miowing/mioverify/util/DataUtil.java | 21 +- .../org/miowing/mioverify/util/TokenUtil.java | 59 +++++ src/main/resources/application.yml | 124 ++++++----- src/main/resources/banner.txt | 3 +- 33 files changed, 935 insertions(+), 99 deletions(-) create mode 100644 src/main/java/org/miowing/mioverify/config/SecurityConfig.java create mode 100644 src/main/java/org/miowing/mioverify/config/package-info.java create mode 100644 src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java rename src/main/java/org/miowing/mioverify/pojo/{request => oauth}/OauthUserRegisterReq.java (91%) create mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java create mode 100644 src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java diff --git a/pom.xml b/pom.xml index 8fbd246..bdae78a 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,11 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-security-oauth2-client + 4.0.3 + diff --git a/src/main/java/org/miowing/mioverify/config/HttpConfig.java b/src/main/java/org/miowing/mioverify/config/HttpConfig.java index 32252fa..fe43ea5 100644 --- a/src/main/java/org/miowing/mioverify/config/HttpConfig.java +++ b/src/main/java/org/miowing/mioverify/config/HttpConfig.java @@ -9,11 +9,13 @@ import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; @Configuration public class HttpConfig { @Autowired private DataUtil dataUtil; + @Bean public TomcatServletWebServerFactory servletWebServerFactory(Connector connector) { if (!dataUtil.isSslEnabled()) { @@ -33,6 +35,7 @@ protected void postProcessContext(Context context) { serverFactory.addAdditionalTomcatConnectors(connector); return serverFactory; } + @Bean public Connector createHttpConnector() { Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); @@ -42,4 +45,11 @@ public Connector createHttpConnector() { connector.setRedirectPort(dataUtil.getPort()); return connector; } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + // + } + } \ 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..4f595db --- /dev/null +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -0,0 +1,208 @@ +package org.miowing.mioverify.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp; +import org.miowing.mioverify.service.OAuthService; +import org.miowing.mioverify.util.DataUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +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.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + *

Spring Security 全局安全配置类

+ * + *

本类承担以下职责:

+ *
    + *
  • 禁用 CSRF(项目为无状态 REST API,使用自定义 Token 鉴权)
  • + *
  • 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 自行处理)
  • + *
  • 根据配置文件中的开关,动态注册启用的 OAuth2 Provider
  • + *
  • 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}} 两个端点, + * 由 Spring Security 自动完成授权跳转与 code 换 token 流程
  • + *
  • OAuth2 登录成功后,通过 {@code successHandler} 调用业务层完成用户查找/创建并签发临时 Token
  • + *
+ * + *

与原有账密登录({@code /authserver/**})完全独立,互不影响。

+ */ +@Configuration +@EnableWebSecurity +@Slf4j +public class SecurityConfig { + + /** + * Spring 自动装配的 ClientRegistrationRepository。 + * 包含配置文件中 Provider 的注册信息(无论是否启用)。 + */ + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + @Autowired + private OAuthService oAuthService; + @Autowired + private DataUtil dataUtil; + + + /** + * 配置 Spring Security 过滤链。 + * + *

过滤链处理顺序:

+ *
    + *
  1. 禁用 CSRF
  2. + *
  3. 设置无状态 Session(不创建 HttpSession)
  4. + *
  5. 放行所有请求
  6. + *
  7. 若 OAuth 总开关开启,注册 OAuth2 登录流程
  8. + *
+ * + * @param http Spring Security 的 HttpSecurity 构造器 + * + * @return 构建完成的 SecurityFilterChain + * + * @throws Exception 配置异常 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer :: disable) + + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() //放行全部接口 + ); + + if ( dataUtil.isOAuthEnabled() ) { + + http.oauth2Login(oauth2 -> oauth2 + // 接管 /oauth/authorize/{provider} + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + // 接管 /oauth/callback/{provider} + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/*") + ) + // 只注册配置文件中 enabled=true 的 Provider + .clientRegistrationRepository(filteredRepository()) + .successHandler(oAuth2SuccessHandler()) + // 登录失败 + .failureUrl("/oauth/error") + ); + } + + return http.build(); + } + + /** + * OAuth2 登录成功处理器。 + * + *

当 Spring Security 完成以下工作后,本处理器被调用:

+ *
    + *
  • 用 code 向 Provider 换取 Access Token
  • + *
  • 用 Access Token ( OAuth 的 Token ) 调用 UserInfo 端点获取用户信息
  • + *
+ * + *

本处理器负责:

+ *
    + *
  • 从 {@link OAuth2User} 中提取 providerUserId 和 providerUsername
  • + *
  • 调用 {@link org.miowing.mioverify.service.OAuthService#handleOAuthLogin} 完成用户查找或创建
  • + *
  • 签发临时 Token,返回给前端
  • + *
  • 前端用临时 Token 调用 {@code POST /oauth/authenticate} 换取正式 accessToken
  • + *
+ * + * @return {@link AuthenticationSuccessHandler} 实例 + */ + private AuthenticationSuccessHandler oAuth2SuccessHandler() { + return (request, response, authentication) -> { + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; + OAuth2User oAuth2User = token.getPrincipal(); + String provider = token.getAuthorizedClientRegistrationId(); + + // 不同 Provider 的用户名字段名不同,按优先级取 + String providerUserId = oAuth2User.getName(); + String providerUsername = resolveUsername(oAuth2User); + + log.info("OAuth2 authorization success: provider={}, userId={}, username={}", + provider, providerUserId, providerUsername); + + // 交给 OAuthService 处理用户查找/创建,返回临时 Token + OAuthCallbackResp resp = oAuthService.handleOAuthLogin( + provider, providerUserId, providerUsername + ); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpStatus.OK.value()); + // 直接复用 OAuthCallbackResp,Jackson 序列化 + response.getWriter().write(new ObjectMapper().writeValueAsString(resp)); + }; + } + + /** + * 根据不同 Provider 解析用户名。 + * + *
    + *
  • GitHub:使用 {@code login} 字段
  • + *
  • OIDC(mcjpg / microsoft):使用 {@code preferred_username} 或 {@code name}
  • + *
+ * + * @param oAuth2User Spring Security 封装的 OAuth2 用户对象 + * + * @return 解析到的用户名,可能为 null + */ + private String resolveUsername(OAuth2User oAuth2User) { + if ( oAuth2User.getAttribute("login") != null ) + return oAuth2User.getAttribute("login"); // GitHub + if ( oAuth2User.getAttribute("displayName") != null ) + return oAuth2User.getAttribute("displayName"); // MCJPG + return oAuth2User.getAttribute("name"); // Microsoft / 其他 + } + + /** + * 根据配置文件中各 Provider 的 {@code enabled} 开关, + * 过滤出实际启用的 Provider,构建新的 {@link ClientRegistrationRepository}。 + * + *

未启用的 Provider 不会参与 OAuth2 流程, + * 即使配置文件中写了 client-id / client-secret 也不会生效。

+ * + * @return 仅包含已启用 Provider 的 ClientRegistrationRepository + */ + private ClientRegistrationRepository filteredRepository() { + Map enabledMap = Map.of( + "github", dataUtil.isOAuthGitHubEnabled(), + "microsoft", dataUtil.isOAuthMicrosoftEnabled(), + "mcjpg", dataUtil.isOAuthMcjpgEnabled(), + "custom", dataUtil.isOAuthCustomEnabled() + ); + + List activeList = enabledMap.entrySet().stream() + .filter(Map.Entry :: getValue) + .map(e -> clientRegistrationRepository.findByRegistrationId(e.getKey())) + .filter(Objects :: nonNull) + .peek(r -> log.info("OAuth2 Provider enabled: {}", r.getRegistrationId())) + .toList(); + + if ( activeList.isEmpty() ) { + log.warn("OAuth2 enabled but no provider is enabled!"); + throw new IllegalStateException("OAuth2 enabled but no provider is enabled!"); + } + + return new InMemoryClientRegistrationRepository(activeList); + } + +} diff --git a/src/main/java/org/miowing/mioverify/config/package-info.java b/src/main/java/org/miowing/mioverify/config/package-info.java new file mode 100644 index 0000000..006eb93 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/config/package-info.java @@ -0,0 +1,4 @@ +/** + * 配置 + */ +package org.miowing.mioverify.config; \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java b/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java index 1126466..4e10342 100644 --- a/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java +++ b/src/main/java/org/miowing/mioverify/controller/ExceptionProcessor.java @@ -75,4 +75,10 @@ public ResponseEntity handleForbidden() { // } + @ExceptionHandler(ServerConfigurationException.class) + public ResponseEntity handleServerError() { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + // + } + } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/controller/ExternController.java b/src/main/java/org/miowing/mioverify/controller/ExternController.java index 29cea9a..9e0ba49 100644 --- a/src/main/java/org/miowing/mioverify/controller/ExternController.java +++ b/src/main/java/org/miowing/mioverify/controller/ExternController.java @@ -57,6 +57,7 @@ public ResponseEntity registerUser(@RequestBody UserRegisterReq req) { userService.save(user); return new ResponseEntity<>(HttpStatus.OK); } + @PostMapping("/register/profile") public ResponseEntity registerProfile(@RequestBody ProfileRegisterReq req) { if (!dataUtil.isAllowRegister()) { diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java index 1bcce99..5ee0237 100644 --- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java +++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java @@ -1,8 +1,20 @@ package org.miowing.mioverify.controller; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import org.miowing.mioverify.exception.FeatureNotSupportedException; +import org.miowing.mioverify.dao.UserDao; +import org.miowing.mioverify.exception.*; +import org.miowing.mioverify.pojo.AToken; +import org.miowing.mioverify.pojo.Profile; +import org.miowing.mioverify.pojo.ProfileShow; +import org.miowing.mioverify.pojo.User; +import org.miowing.mioverify.pojo.oauth.OAuthAuthReq; +import org.miowing.mioverify.pojo.oauth.OAuthBindReq; +import org.miowing.mioverify.pojo.oauth.OAuthProviderListResp; +import org.miowing.mioverify.pojo.oauth.OAuthStatusResp; +import org.miowing.mioverify.pojo.response.AuthResp; +import org.miowing.mioverify.service.OAuthService; import org.miowing.mioverify.service.ProfileService; import org.miowing.mioverify.service.RedisService; import org.miowing.mioverify.service.UserService; @@ -10,11 +22,13 @@ import org.miowing.mioverify.util.TokenUtil; import org.miowing.mioverify.util.Util; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; +import java.util.List; + /** * OAuth 2.0 / OIDC 相关控制器 * 所有端点均以 /oauth 开头,处理第三方登录、绑定、解绑等操作 @@ -24,6 +38,9 @@ @RequestMapping("/oauth") public class OAuthController { + @Autowired + private UserDao userDao; + @Autowired private UserService userService; @@ -33,6 +50,9 @@ public class OAuthController { @Autowired private RedisService redisService; + @Autowired + private OAuthService oAuthService; + @Autowired private DataUtil dataUtil; @@ -42,26 +62,137 @@ public class OAuthController { @Autowired private Util util; + /** + *

OAuth 状态查询

+ * 检查 OAuth 是否启用及可用的提供商列表 + */ + @GetMapping("/status") + public OAuthStatusResp status() { + if ( ! dataUtil.isOAuthEnabled() ) { + return new OAuthStatusResp().setEnabled(false).setProviders(null); + } + List providers = new ArrayList<>(); + if ( dataUtil.isOAuthMicrosoftEnabled() ) providers.add("microsoft"); + if ( dataUtil.isOAuthGitHubEnabled() ) providers.add("gitHub"); + if ( dataUtil.isOAuthMcjpgEnabled() ) providers.add("mcjpg"); + if ( dataUtil.isOAuthCustomEnabled() ) providers.add("custom"); + return new OAuthStatusResp().setEnabled(true).setProviders(providers); + } + + /** + * 用临时 token 换取应用 accessToken(Yggdrasil 风格) + */ + @PostMapping("/authenticate") + public AuthResp authenticate(@RequestBody OAuthAuthReq req) { + if ( ! dataUtil.isOAuthEnabled() ) { + throw new FeatureNotSupportedException(); + } + + // 消费临时 token + String userId = redisService.consumeOAuthTempToken(req.getTempToken()); + if ( userId == null ) { + throw new InvalidTokenException(); + } + + User user = userService.getById(userId); + if ( user == null ) { + throw new InvalidTokenException(); + } + + // 检查角色 + List aProfiles = profileService.getByUserId(user.getId()); + if ( aProfiles.isEmpty() ) { + throw new NoProfileException(); + } + + List profiles = util.profileToShow(aProfiles, true); + ProfileShow bindProfile = profiles.get(0); + + String cToken = req.getClientToken() == null ? TokenUtil.genClientToken() : req.getClientToken(); + String aToken = tokenUtil.genAccessToken(user, cToken, bindProfile.getId()); + redisService.saveToken(aToken, user.getId()); + + log.info("New login with OAuth came: {}", user.getUsername()); + + return new AuthResp() + .setAccessToken(aToken) + .setClientToken(cToken) + .setAvailableProfiles(profiles) + .setSelectedProfile(bindProfile) + .setUser(req.getRequestUser() ? util.userToShow(user) : null); + } + + /** + * 获取所有支持的 OAuth 提供商及当前用户的绑定状态 + */ + @GetMapping("/providers") + public OAuthProviderListResp getProviders(HttpServletRequest request) { + if ( ! dataUtil.isOAuthEnabled() ) { + throw new FeatureNotSupportedException(); + } + + //TODO + + return null; + } /** - * 跳转到 OAuth 提供商授权页 - * 可选参数 bind=true 表示此次授权用于绑定到当前已登录账号 + * 绑定第三方账号到当前用户(需要登录) */ - @GetMapping("/authorize/{provider}") - public ResponseEntity authorize( + @PostMapping("/bind/{provider}") + public ResponseEntity bindProvider( @PathVariable String provider, - @RequestParam(value = "bind", defaultValue = "false") boolean bind, + @RequestBody OAuthBindReq req, HttpServletRequest request) { - if (!dataUtil.isOAuthMode()) { + if ( ! dataUtil.isOAuthEnabled() ) { throw new FeatureNotSupportedException(); } - // 构建授权 URL,将 bind 状态和当前登录用户信息存入 state TODO -// String url = OAuthService.buildAuthorizationUrl(provider, bind, request); - HttpHeaders headers = new HttpHeaders(); -// headers.setLocation(URI.create(url)); - return new ResponseEntity<>(headers, HttpStatus.FOUND); + + //TODO + + + return ResponseEntity.ok().build(); } + /** + * 解绑第三方账号 + */ + @DeleteMapping("/bind/{provider}") + public ResponseEntity unbindProvider( + @PathVariable String provider, + @RequestHeader("Authorization") String authorization) { + if ( ! dataUtil.isOAuthEnabled() ) { + throw new FeatureNotSupportedException(); + } + if ( authorization == null || ! authorization.startsWith("Bearer ") ) { + throw new UnauthorizedException(); // 401 + } + + String accessToken = authorization.substring(7); + AToken aToken = tokenUtil.verifyAccessToken(accessToken, null, true); + + if ( aToken == null ) { + throw new UnauthorizedException(); // 401 + } + + User user = userDao.selectOne(new LambdaQueryWrapper().eq(User :: getUsername, aToken.name())); + + if ( user == null ) { + throw new UnauthorizedException(); // 401 + } + oAuthService.unbind(user.getId(), provider); // 取消成功和未绑定均返回 204 + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * OAuth2 登录失败后的跳转端点 + * 由 Spring Security 在 OAuth2 流程失败时自动重定向至此 + */ + @GetMapping("/error") + public void oauthError() { + throw new LoginFailedException(); + } } diff --git a/src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java b/src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java new file mode 100644 index 0000000..8f1d4be --- /dev/null +++ b/src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java @@ -0,0 +1,5 @@ +package org.miowing.mioverify.exception; + +public class ServerConfigurationException extends MioVerifyException { + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/Profile.java b/src/main/java/org/miowing/mioverify/pojo/Profile.java index 9e0928e..6c4e41c 100644 --- a/src/main/java/org/miowing/mioverify/pojo/Profile.java +++ b/src/main/java/org/miowing/mioverify/pojo/Profile.java @@ -6,6 +6,9 @@ import lombok.experimental.Accessors; import org.springframework.lang.Nullable; +/** + *

角色 数据表映射

+ */ @Data @Accessors(chain = true) @TableName("profiles") diff --git a/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java b/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java index be45f69..c2f3690 100644 --- a/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java +++ b/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java @@ -7,6 +7,24 @@ import java.util.List; +/** + *

角色信息

+ * 序列化后的 Json 格式,不对应数据库 + *
+ * {
+ * 	"id":"角色 UUID(无符号)",
+ * 	"name":"角色名称",
+ * 	"properties":[ // 角色的属性(数组,每一元素为一个属性)(仅在特定情况下需要包含)
+ *                { // 一项属性
+ * 			"name":"属性的名称",
+ * 			"value":"属性的值",
+ * 			"signature":"属性值的数字签名(仅在特定情况下需要包含)"
+ *        }
+ * 		// ,...(可以有更多)
+ * 	]
+ * }
+ * 
+ */ @Data @Accessors(chain = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java b/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java index 6dc9bfe..59426e0 100644 --- a/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java +++ b/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java @@ -10,6 +10,10 @@ import java.util.List; +/** + *

服务器元数据

+ * 返回 Json 格式 + */ @Data @Accessors(chain = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java b/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java index 6ab4a56..6af2eaf 100644 --- a/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java +++ b/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java @@ -5,6 +5,27 @@ import lombok.experimental.Accessors; import org.springframework.lang.Nullable; +/** + *

材质信息

+ * 序列化后的 Json 格式,不对应数据库 + *
+ * {
+ * 	"timestamp":该属性值被生成时的时间戳(Java 时间戳格式,即自 1970-01-01 00:00:00 UTC 至今经过的毫秒数),
+ * 	"profileId":"角色 UUID(无符号)",
+ * 	"profileName":"角色名称",
+ * 	"textures":{ // 角色的材质
+ * 		"材质类型(如 SKIN)":{ // 若角色不具有该项材质,则不必包含
+ * 			"url":"材质的 URL",
+ * 			"metadata":{ // 材质的元数据,若没有则不必包含
+ * 				"名称":"值"
+ * 				// ,...(可以有更多)
+ *            }
+ *        }
+ * 		// ,...(可以有更多)
+ *    }
+ * }
+ * 
+ */ @Data @Accessors(chain = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/src/main/java/org/miowing/mioverify/pojo/User.java b/src/main/java/org/miowing/mioverify/pojo/User.java index 2c80c76..49a58b1 100644 --- a/src/main/java/org/miowing/mioverify/pojo/User.java +++ b/src/main/java/org/miowing/mioverify/pojo/User.java @@ -6,6 +6,9 @@ import lombok.experimental.Accessors; import org.springframework.lang.Nullable; +/** + *

用户 数据表映射

+ */ @Data @Accessors(chain = true) @TableName("users") diff --git a/src/main/java/org/miowing/mioverify/pojo/UserShow.java b/src/main/java/org/miowing/mioverify/pojo/UserShow.java index 46ffa4f..ecd9f75 100644 --- a/src/main/java/org/miowing/mioverify/pojo/UserShow.java +++ b/src/main/java/org/miowing/mioverify/pojo/UserShow.java @@ -3,8 +3,25 @@ import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; import lombok.experimental.Accessors; + import java.util.List; +/** + *

用户信息

+ * 序列化后的 Json 格式,不对应数据库 + *
+ * {
+ * 	"id":"用户的 ID",
+ * 	"properties":[ // 用户的属性(数组,每一元素为一个属性)
+ *                { // 一项属性
+ * 			"name":"属性的名称",
+ * 			"value":"属性的值",
+ *        }
+ * 		// ,...(可以有更多)
+ * 	]
+ * }
+ * 
+ */ @Data @Accessors(chain = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java new file mode 100644 index 0000000..9ba9467 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java @@ -0,0 +1,22 @@ +package org.miowing.mioverify.pojo.oauth; + +import lombok.Data; +import org.springframework.lang.Nullable; + +/** + * 用临时 Token 换取正式 accessToken 的请求体 + * 用于 POST /oauth/authenticate + */ +@Data +public class OAuthAuthReq { + + /** OAuth 登录回调后返回的临时 Token */ + private String tempToken; + + /** 客户端 Token,为空时由服务端生成 */ + private @Nullable String clientToken; + + /** 是否在响应中返回用户信息 */ + private Boolean requestUser; + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java new file mode 100644 index 0000000..daeab4f --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java @@ -0,0 +1,19 @@ +package org.miowing.mioverify.pojo.oauth; + +import lombok.Data; + + +/** + * 手动绑定第三方账号的请求体 + * 用于 POST /oauth/{provider}/bind + */ +@Data +public class OAuthBindReq { + + /** Provider 返回的用户唯一 ID */ + private String providerUserId; + + /** Provider 返回的用户名 */ + private String providerUsername; + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java new file mode 100644 index 0000000..66430a9 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java @@ -0,0 +1,20 @@ +package org.miowing.mioverify.pojo.oauth; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * OAuth 回调响应 + * 用于 /oauth/callback/{provider} 接口返回临时令牌和用户信息 + */ +@Data +@Accessors(chain = true) +public class OAuthCallbackResp { + + private String tempToken; // 临时令牌,用于后续换取 accessToken + private String provider; // 提供商名称 (github/microsoft/mcjpg/custom) + private String providerUsername; // 第三方平台上的用户名 + private boolean needProfile; // 是否需要创建角色(profile) + private String userId; // 当 needProfile=true 时返回用户ID,便于前端后续创建角色 + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java new file mode 100644 index 0000000..6078559 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java @@ -0,0 +1,5 @@ +package org.miowing.mioverify.pojo.oauth; + +public class OAuthProviderListResp { + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java new file mode 100644 index 0000000..b499d72 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java @@ -0,0 +1,14 @@ +package org.miowing.mioverify.pojo.oauth; + +import lombok.Data; +import org.springframework.lang.Nullable; + +@Data +public class OAuthState { + + private String provider; + private boolean bind; // 是否为绑定模式 + private @Nullable String userId; // 绑定模式下的当前用户ID + private String redirectUri; // 登录成功后重定向地址 + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java new file mode 100644 index 0000000..ec71ba4 --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java @@ -0,0 +1,23 @@ +package org.miowing.mioverify.pojo.oauth; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * OAuth 状态获取响应 + */ +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OAuthStatusResp { + + /** OAuth 是否已经启用 */ + private boolean enabled; + + /** Provider 列表 */ + private List providers; + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java new file mode 100644 index 0000000..0faad5b --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java @@ -0,0 +1,14 @@ +package org.miowing.mioverify.pojo.oauth; + +import lombok.Data; +import org.springframework.lang.Nullable; + +@Data +public class OAuthUserInfo { + + private String provider; // 提供商名称 + private String providerUserId; // 提供商返回的唯一用户ID + private @Nullable String providerUsername; // 提供商返回的用户名 + private @Nullable String email; // 邮箱 + +} diff --git a/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java similarity index 91% rename from src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java rename to src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java index 9d24416..99a4d13 100644 --- a/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java @@ -1,4 +1,4 @@ -package org.miowing.mioverify.pojo.request; +package org.miowing.mioverify.pojo.oauth; import lombok.Data; import org.springframework.lang.Nullable; diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java b/src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java new file mode 100644 index 0000000..940cd5f --- /dev/null +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java @@ -0,0 +1 @@ +package org.miowing.mioverify.pojo.oauth; \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/service/OAuthService.java b/src/main/java/org/miowing/mioverify/service/OAuthService.java index d166d7e..22cb616 100644 --- a/src/main/java/org/miowing/mioverify/service/OAuthService.java +++ b/src/main/java/org/miowing/mioverify/service/OAuthService.java @@ -1,27 +1,28 @@ package org.miowing.mioverify.service; -import jakarta.servlet.http.HttpServletRequest; - -import java.util.List; +import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp; public interface OAuthService { + /** - * 获取启用的OAuth提供商列表(内部标识) + * OAuth2 登录成功后的用户处理(由 SecurityConfig.successHandler 调用)。 + * Spring Security 已自动完成 code 换 token 和获取用户信息, + * 此方法负责 查找/创建本地用户 并签发临时 Token。 + * + * @param provider Provider 名称(github / microsoft / mcjpg / custom) + * @param providerUserId Provider 返回的用户唯一 ID + * @param providerUsername Provider 返回的用户名 + * + * @return OAuthCallbackResp 包含临时 Token 等信息 */ - List getEnabledProviders(); - -// /** TODO -// * 获取所有支持的OAuth提供商详情 -// */ -// List getSupportedProviders(); + OAuthCallbackResp handleOAuthLogin(String provider, String providerUserId, String providerUsername); /** - * 构建授权跳转URL + * 解绑用户与指定 OAuth 提供商的关联 + * + * @param userId 用户 ID + * @param provider 提供商名称(如 "microsoft") */ - String buildAuthorizationUrl(String provider, boolean bind, HttpServletRequest request); + void unbind(String userId, String provider); -// /** TODO -// * 用授权码换取用户信息 -// */ -// OAuthUserInfo exchangeCodeAndGetUserInfo(String provider, String code); } diff --git a/src/main/java/org/miowing/mioverify/service/RedisService.java b/src/main/java/org/miowing/mioverify/service/RedisService.java index b64cfb4..daeac14 100644 --- a/src/main/java/org/miowing/mioverify/service/RedisService.java +++ b/src/main/java/org/miowing/mioverify/service/RedisService.java @@ -1,9 +1,29 @@ package org.miowing.mioverify.service; public interface RedisService { + void saveToken(String token, String userId); boolean checkToken(String token); void removeToken(String token); void clearToken(String userId); void saveSession(String serverId, String token); + + /** + * 保存 OAuth 临时令牌,用于后续换取正式的 accessToken。 + * 临时令牌需设置较短的过期时间,防止滥用。 + * + * @param tempToken 生成的临时令牌(通常为 UUID) + * @param userId 对应的用户 ID + */ + void saveOAuthTempToken(String tempToken, String userId); + + /** + * 消费 OAuth 临时令牌。 + * 根据临时令牌获取用户 ID,并立即删除该记录,确保一次性使用。 + * + * @param tempToken 待消费的临时令牌 + * @return 若令牌有效且未过期,返回对应的用户 ID;否则返回 null + */ + String consumeOAuthTempToken(String tempToken); + } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/service/UserService.java b/src/main/java/org/miowing/mioverify/service/UserService.java index 55f9fea..7a9b278 100644 --- a/src/main/java/org/miowing/mioverify/service/UserService.java +++ b/src/main/java/org/miowing/mioverify/service/UserService.java @@ -7,4 +7,13 @@ public interface UserService extends IService { User getLogin(String username, String password); User getLogin(String username, String password, boolean exception); User getLoginNoPwd(String username); + + /** + * 按 provider 的用户ID 查询用户(对应各字段的 UNIQUE 查询) + * + * @param providerUserId 用户 ID + * + * @return {@link User} 实例 + */ + User getByProviderUserId(String provider, String providerUserId); } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java new file mode 100644 index 0000000..b7be4dc --- /dev/null +++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java @@ -0,0 +1,136 @@ +package org.miowing.mioverify.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.miowing.mioverify.dao.UserDao; +import org.miowing.mioverify.exception.FeatureNotSupportedException; +import org.miowing.mioverify.pojo.User; +import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp; +import org.miowing.mioverify.service.OAuthService; +import org.miowing.mioverify.service.ProfileService; +import org.miowing.mioverify.service.RedisService; +import org.miowing.mioverify.service.UserService; +import org.miowing.mioverify.util.DataUtil; +import org.miowing.mioverify.util.TokenUtil; +import org.miowing.mioverify.util.Util; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +@Service +@Slf4j +public class OAuthServiceImpl implements OAuthService { + + @Autowired + private TokenUtil tokenUtil; + @Autowired + private DataUtil dataUtil; + @Autowired + private Util util; + + @Autowired + private RedisService redisService; + @Autowired + private UserService userService; + @Autowired + private ProfileService profileService; + + @Autowired + private UserDao userDao; + + + /** Provider 与数据库字段设置器的映射(用于设置对应的 provider ID) */ + private static final java.util.Map> PROVIDER_SETTER = java.util.Map.of( + "github", User :: setGithubId, + "microsoft", User :: setMicrosoftId, + "mcjpg", User :: setMcjpgId, + "custom", User :: setCustomId + ); + + /** Provider 与数据库字段名的映射(用于查询) */ + private static final java.util.Map PROVIDER_FIELD = java.util.Map.of( + "github", "github_id", + "microsoft", "microsoft_id", + "mcjpg", "mcjpg_id", + "custom", "custom_id" + ); + + + @Override + public OAuthCallbackResp handleOAuthLogin( + @NonNull String provider, @NonNull String providerUserId, String providerUsername) { + + // 参数校验 + BiConsumer setter = PROVIDER_SETTER.get(provider); + String fieldName = PROVIDER_FIELD.get(provider); + if ( setter == null || fieldName == null ) { + throw new FeatureNotSupportedException(); //不支持的 OAuth 提供商 + } + if ( providerUserId.isBlank() ) { + throw new IllegalArgumentException(); // 提供商用户 ID 不能为空 + } + + User user = userDao.selectOne( + new QueryWrapper().eq(fieldName, providerUserId) + ); + + if ( user == null ) { + user = createUser(provider, providerUserId, providerUsername, setter); + } + + // 生成临时令牌并存入 Redis + String tempToken = Util.genUUID(); + redisService.saveOAuthTempToken(tempToken, user.getId()); + + return new OAuthCallbackResp() + .setTempToken(tempToken) + .setProvider(provider) + .setProviderUsername(providerUsername) + .setNeedProfile(false); //暂时设置为 false + } + + + @Override + public void unbind(@NonNull String userId, @NonNull String provider) { + BiConsumer setter = PROVIDER_SETTER.get(provider); + if ( setter == null ) { + throw new FeatureNotSupportedException();//不支持的 OAuth 提供商 + } + + User user = userDao.selectById(userId); + + // 清空对应的 provider ID + setter.accept(user, null); + userDao.updateById(user); + + log.info("User: {} ,Unbind oauth provider: {}", userId, provider); + } + + + //----------- + + + /** + * 创建新用户 + */ + private User createUser(String provider, String providerUserId, String providerUsername, + BiConsumer setter) { + + User newUser = new User(); + newUser.setId(Util.genUUID()); // 使用工具类生成 ID + // 用户名处理:优先使用提供商返回的用户名,否则生成默认名 + String username = (providerUsername != null && ! providerUsername.isBlank()) + ? providerUsername + : "user_" + providerUserId.substring(0, Math.min(8, providerUserId.length())); + newUser.setUsername(username); + // 设置对应的 provider ID + setter.accept(newUser, providerUserId); + + log.info("New user register: {}", newUser.getUsername()); + userService.save(newUser); + return newUser; + } + +} 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..ac25ffe 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java @@ -8,24 +8,37 @@ import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; + import java.util.Set; +import java.util.concurrent.TimeUnit; +/** + *

Redis 操作

+ */ @Service public class RedisServiceImpl implements RedisService { + @Autowired private StringRedisTemplate redisTemplate; @Autowired private DataUtil dataUtil; + + + private static final String OAUTH_STATE_PREF = "oauth:state:"; + private static final String OAUTH_TEMP_TOKEN_PREF = "oauth:temp:"; + @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, ""); } + @Override public boolean checkToken(String token) { return Boolean.TRUE.equals(redisTemplate.hasKey(TokenUtil.TOKEN_PREF + token)); } + @Override public void removeToken(String token) { String userId = redisTemplate.opsForValue().get(TokenUtil.TOKEN_PREF + token); @@ -39,6 +52,7 @@ public void removeToken(String token) { hops.delete(userIdP, token); } } + @Override public void clearToken(String userId) { String userIdP = TokenUtil.USERID_PREF + userId; @@ -49,8 +63,22 @@ public void clearToken(String userId) { } redisTemplate.delete(userIdP); } + @Override public void saveSession(String serverId, String token) { redisTemplate.opsForValue().set(SessionUtil.SESSION_PREF + serverId, token, dataUtil.getSessionExpire()); } + + @Override + public void saveOAuthTempToken(String tempToken, String userId) { + String key = OAUTH_TEMP_TOKEN_PREF + tempToken; + redisTemplate.opsForValue().set(key, userId, dataUtil.getOauthExpire().getSeconds(), TimeUnit.SECONDS); + } + + @Override + public String consumeOAuthTempToken(String tempToken) { + String key = OAUTH_TEMP_TOKEN_PREF + tempToken; + return redisTemplate.opsForValue().getAndDelete(key); + } + } \ 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..71ade7e 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java @@ -14,7 +14,9 @@ public class UserServiceImpl extends ServiceImpl implements UserS @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<>(); @@ -25,10 +27,27 @@ public User getLogin(String username, String password, boolean exception) { } return user; } + @Override public @Nullable User getLoginNoPwd(String username) { LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); lqw.eq(User::getUsername, username); return getOne(lqw); } + + @Override + public User getByProviderUserId(String provider, String providerUserId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + switch ( provider.toLowerCase() ) { + case "github" -> lqw.eq(User :: getGithubId, providerUserId); + case "microsoft" -> lqw.eq(User :: getMicrosoftId, providerUserId); + case "mcjpg" -> lqw.eq(User :: getMcjpgId, providerUserId); + case "custom" -> lqw.eq(User :: getCustomId, providerUserId); + default -> { + return null; + } + } + return getOne(lqw); + } + } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/util/DataUtil.java b/src/main/java/org/miowing/mioverify/util/DataUtil.java index 7399fbe..05fe3af 100644 --- a/src/main/java/org/miowing/mioverify/util/DataUtil.java +++ b/src/main/java/org/miowing/mioverify/util/DataUtil.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; + import java.nio.file.Path; import java.time.Duration; @@ -62,13 +63,19 @@ public class DataUtil implements InitializingBean { @Value("${mioverify.cors.allowed-origin}") private String corsAllowedOrigin; - //以下内容为 Oauth 配置 - /** - * 是否启用 Oauth 模式 - */ - @Value("${mioverify.oauth.enabled}") - private boolean isOAuthMode; - + //以下为 Oauth 设置 + @Value("${spring.security.oauth2.enabled}") + private boolean oAuthEnabled; + @Value("${spring.security.oauth2.client.registration.github.enabled}") + private boolean oAuthGitHubEnabled; + @Value("${spring.security.oauth2.client.registration.microsoft.enabled}") + private boolean oAuthMicrosoftEnabled; + @Value("${spring.security.oauth2.client.registration.mcjpg.enabled}") + private boolean oAuthMcjpgEnabled; + @Value("${spring.security.oauth2.client.registration.custom.enabled}") + private boolean oAuthCustomEnabled; + @Value("${spring.security.oauth2.expire:5m}") + private Duration oauthExpire; @Override public void afterPropertiesSet() throws Exception { diff --git a/src/main/java/org/miowing/mioverify/util/TokenUtil.java b/src/main/java/org/miowing/mioverify/util/TokenUtil.java index 38ee2a5..790e6f8 100644 --- a/src/main/java/org/miowing/mioverify/util/TokenUtil.java +++ b/src/main/java/org/miowing/mioverify/util/TokenUtil.java @@ -8,12 +8,16 @@ import com.auth0.jwt.exceptions.SignatureVerificationException; import com.auth0.jwt.exceptions.TokenExpiredException; import com.auth0.jwt.interfaces.DecodedJWT; +import jakarta.servlet.http.HttpServletRequest; import org.miowing.mioverify.pojo.AToken; +import org.miowing.mioverify.pojo.Profile; import org.miowing.mioverify.pojo.User; +import org.miowing.mioverify.service.ProfileService; import org.miowing.mioverify.service.RedisService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; + import java.util.Date; /** @@ -25,12 +29,31 @@ public class TokenUtil { private DataUtil dataUtil; @Autowired private RedisService redisService; + @Autowired + private ProfileService profileService; + + //TODO 控制优化 public static String TOKEN_PREF = "tk_"; public static String TMARK_PREF = "tm_"; public static String USERID_PREF = "tv_"; + + /** + * 生成随机的客户端令牌 + * + * @return 客户端令牌字符串 + */ public static String genClientToken() { return IdUtil.simpleUUID(); + // } + + /** + * 生成访问令牌(JWT) + * @param user 用户对象 + * @param clientToken 客户端令牌(可为 null,此时自动生成) + * @param bindProfile 绑定的角色 ID + * @return JWT 访问令牌 + */ public String genAccessToken(User user, @Nullable String clientToken, String bindProfile) { Date invalidAt = new Date(System.currentTimeMillis() + dataUtil.getTokenInvalid().toMillis()); return JWT.create() @@ -41,6 +64,14 @@ public String genAccessToken(User user, @Nullable String clientToken, String bin .withExpiresAt(invalidAt) .sign(Algorithm.HMAC256(dataUtil.getTokenSign())); } + + /** + * 验证访问令牌的有效性 + * @param accessToken 访问令牌字符串 + * @param clientToken 客户端令牌(可为 null,不校验) + * @param strictExpire 是否严格检查弱过期时间(wexp) + * @return 如果验证通过返回 {@link AToken} 对象,否则返回 null + */ public @Nullable AToken verifyAccessToken(String accessToken, @Nullable String clientToken, boolean strictExpire) { try { DecodedJWT dJWT = JWT @@ -73,4 +104,32 @@ public String genAccessToken(User user, @Nullable String clientToken, String bin return null; } } + + /** + * 从 HTTP 请求中解析当前登录用户的 ID + *

+ * 从 Authorization 头中提取 Bearer 令牌,验证令牌有效性,并通过绑定的角色 ID 获取用户 ID。 + * 令牌必须有效且未过期(严格模式)。 + *

+ * + * @param request HTTP 请求对象 + * + * @return 当前登录用户的 ID,如果未登录或令牌无效则返回 null + */ + public @Nullable String getCurrentUserId(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); + if ( authHeader == null || ! authHeader.startsWith("Bearer ") ) { + return null; + } + String token = authHeader.substring(7); + // 验证 token,严格检查过期时间(strictExpire = true) + AToken aToken = verifyAccessToken(token, null, true); + if ( aToken == null ) { + return null; + } + // 通过绑定的 Profile ID 获取用户 ID + Profile profile = profileService.getById(aToken.bindProfile()); + return profile != null ? profile.getBindUser() : null; + } + } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7797c8f..a78d578 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,60 @@ server: key-alias: boot servlet: application-display-name: MioVerify Alpha + + spring: + + security: + oauth2: + enabled: true + # State 和 TempToken 有效期 + expire: 5m + client: + registration: + + github: + enabled: true + client-id: "your-github-client-id" + client-secret: "your-github-client-secret" + redirect-uri: "{baseUrl}/oauth/callback/github" + scope: read:user, user:email + + microsoft: + enabled: true + client-id: "client-id" + client-secret: "client-secret" + redirect-uri: "{baseUrl}/oauth/callback/microsoft" + scope: openid, profile, email + + mcjpg: + enabled: true + client-id: "client-id" + client-secret: "client-secret" + redirect-uri: "{baseUrl}/oauth/callback/mcjpg" + scope: openid, profile, email + + custom: + enabled: true + client-id: "client-id" + client-secret: "client-secret" + redirect-uri: "{baseUrl}/oauth/callback/custom" + scope: read + client-name: "custom" + + provider: + microsoft: + issuer-uri: "https://login.microsoftonline.com/your-tenant-id/v2.0" + user-name-attribute: name + mcjpg: + issuer-uri: "https://sso.mcjpg.org" + user-name-attribute: sub + custom: + authorization-uri: "https://example.com" + token-uri: "https://example.com" + user-info-uri: "https://example.com" + user-name-attribute: sub + servlet: multipart: # 允许文件上传 @@ -36,9 +89,13 @@ spring: # url: ... host: localhost port: 6379 + + mybatis-plus: global-config: banner: false + + mioverify: # 跨域配置,生产环境中必须设置 @@ -48,66 +105,6 @@ mioverify: # 允许跨域的域名(* 表示允许所有,但生产环境不建议。) allowed-origin: "https://your-domain.com" - # Oauth 配置项 - oauth: - # 是否启用 Oauth 用户注册,注意不启用则无法注册用户,也不能使用 Oauth 登录 - enabled: true - # OAuth 2 授权码状态有效期 - expire: 5m - microsoft: - enabled: false - # 使用 issuer 自动发现所有端点,这是最推荐的方式 - issuer: "https://login.microsoftonline.com/common/v2.0" - client-id: "自行填写客户端ID" - client-secret: "自行填写客户端密钥" - # 授权成功后的回调地址,GitHub 会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。 - redirect-uri: "http://localhost:8080/oauth/microsoft/callback" - - # OIDC 必须包含 openid,并请求基本信息和刷新令牌 - scope: "openid profile email offline_access" - - github: - enabled: false - # 以下三个内容需自行设置 - client-id: "你的GitHub客户端ID" - client-secret: "你的GitHub客户端密钥" - # 授权成功后的回调地址,GitHub 会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。 - redirect-uri: "http://localhost:8080/oauth/github/callback" - # GitHub 的授权端点 URL - authorization-url: "https://github.com/login/oauth/authorize" - # GitHub 的访问令牌端点 URL - token-url: "https://github.com/login/oauth/access_token" - # GitHub 的用户信息 API 端点 URL - user-info-url: "https://api.github.com/user" - # 向 GitHub 请求的权限范围 - scope: "user:email" - - mcjpg: - enabled: false - # 使用 issuer URL,自动发现所有端点 - issuer: "https://auth.your-domain.com/realms/your-realm" # OIDC 地址 - client-id: "{OIDC_CLIENT_ID}" - client-secret: "{OIDC_CLIENT_SECRET}" - redirect-uri: "http://localhost:8080/oauth/mcjpg/callback" # 回调地址 - # 2. 请求 openid scope,并附带 profile 和 email - scope: "openid profile email" - - custom: - enabled: false - # 以下所有内容需自行设置 - client-id: "客户端ID" - client-secret: "客户端密钥" - # 授权成功后的回调地址,会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。 - redirect-uri: "http://localhost:8080/oauth/custom/callback" - # 授权端点 URL - authorization-url: "https://your-auth-server.com/login/oauth/authorize" - # 访问令牌端点 URL - token-url: "https://your-auth-server.com/login/oauth/access_token" - # 用户信息 API 端点 URL - user-info-url: "https://your-auth-server.com/user" - # 请求的权限范围 - scope: "user:email" - # 扩展API配置项 extern: register: @@ -128,6 +125,7 @@ mioverify: key: admin123098 # 是否允许重复角色名(仅发生在注册和修改) multi-profile-name: true + token: # 给角色(Profile)签名的文本 signature: 'abcd273nsi179a' @@ -135,9 +133,11 @@ mioverify: expire: 10m # token永久过期时间 invalid: 1h + session: # session过期时间 expire: 6m + security: # 公钥路径 public-key-loc: keys/public.pem @@ -147,11 +147,13 @@ mioverify: sign-algorithm: SHA1withRSA # 批量获取角色(Profile)API最大限制数量 profile-batch-limit: 5 + texture: # 材质存储位置 storage-loc: textures # 默认皮肤存储位置 default-skin-loc: textures/skin/default.png + # 服务器元数据,详见Yggdrasil API props: meta: @@ -160,7 +162,7 @@ mioverify: # 实现的名称 implementation-name: mioverify # 实现的版本 - implementation-version: '${project.version}' + implementation-version: '1.4.0' links: # 主页地址 home-page: https://www.baidu.com diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt index 5beb060..6856c94 100644 --- a/src/main/resources/banner.txt +++ b/src/main/resources/banner.txt @@ -3,4 +3,5 @@ / /|_/ / / __ \ | | / / _ \/ ___/ / /_/ / / / / / / / / /_/ / | |/ / __/ / / / __/ /_/ / /_/ /_/_/\____/ |___/\___/_/ /_/_/ \__, / - /____/ \ No newline at end of file + /____/ +Spring Boot Version${spring-boot.formatted-version} === https://github.com/pingguomc/MioVerify \ No newline at end of file From 9b9a04258456b6223be2d7fc998bd58c03c08875 Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:10:49 +0800 Subject: [PATCH 04/12] =?UTF-8?q?wip:=20Oauth=20=E7=B3=BB=E7=BB=9F=20docs:?= =?UTF-8?q?=20API=E6=96=87=E6=A1=A3=20test:=20=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OAuthAPI.md | 115 ++++++++++++++++++ pom.xml | 5 +- .../mioverify/config/SecurityConfig.java | 28 ++++- .../mioverify/controller/OAuthController.java | 13 +- .../pojo/oauth/OAuthCallbackResp.java | 4 +- .../org/miowing/mioverify/util/DataUtil.java | 2 + src/main/resources/application.yml | 12 +- .../mioverify/MioVerifyApplicationTests.java | 5 + .../resources/application-test-sqlite.yml | 4 + 9 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 OAuthAPI.md create mode 100644 src/test/resources/application-test-sqlite.yml diff --git a/OAuthAPI.md b/OAuthAPI.md new file mode 100644 index 0000000..7438377 --- /dev/null +++ b/OAuthAPI.md @@ -0,0 +1,115 @@ +# MioVerify API 接口文档 + +**Yggdrasil-服务端技术规范** 未规定,但在本项目使用的 API 的文档。 + +## 基本约定 + +基本上与 **Yggdrasil-服务端技术规范** 保持一致。 + +TODO: 异常情况和错误类型表格 + +## OAuth 第三方登录/注册 + +### OAuth 状态查询 + +`POST /oauth/status` + +用于检查 OAuth 是否启用及可用的提供商列表。 + +响应格式: + +```json5 +{ + "enabled": true, + //ture/false + "providers": [ + "microsoft", + "github", + "mcjpg", + "custom" + ] + // 已启用的服务商列表 +} +``` + +### 授权端点 + +`GET /oauth/authorize/{provider}` + +将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页。前端应通过新窗口或直接跳转的方式访问此端点。 + +### 回调与重定向端点 + +`GET /oauth/callback/{provider}` + +此端点由 Provider 在用户授权后自动调用,**无需前端直接访问**。 + +后端除了完毕后会将浏览器重定向至配置的 `OAuth 前端回调地址`,并在 URL **查询** 参数中附加以下信息: + +| 参数名 | 类型 | 描述 | +|:-------------:|:-------:|:------------------------------------------------------| +| `tempToken` | string | 临时令牌,用于调用 `POST /oauth/authenticate` 换取 `accessToken` | +| `provider` | string | 使用的 OAuth 提供商名称(如 `github`、`microsoft`) | +| `needProfile` | boolean | 是否需要用户创建角色 | +| `userId` | string | 当 `needProfile=true` 时返回用户 ID,便于前端引导创建角色 | + +### 登录 Yggdrasil + +`POST /oauth/authenticate` + +请求格式: + +```json5 +{ + "tempToken": "OAuth 回调端点后返回的临时 Token", + "clientToken": "由客户端指定的令牌的 clientToken(可选)", + "requestUser": false, + // 是否在响应中包含用户信息,默认 false +} +``` + +若请求中未包含 `clientToken`,服务端应该随机生成一个无符号 UUID 作为 `clientToken`。但需要注意 `clientToken` +可以为任何字符串,即请求中提供任何 `clientToken` 都是可以接受的,不一定要为无符号 UUID。 + +响应格式: + +```json5 +{ + "accessToken": "令牌的 accessToken", + "clientToken": "令牌的 clientToken", + "availableProfiles": [ + // 用户可用角色列表 + // ,... 每一项为一个角色(格式见 §角色信息的序列化) + ], + "selectedProfile": { + // ... 绑定的角色,若为空,则不需要包含(格式见 §角色信息的序列化) + }, + "user": { + // ... 用户信息(仅当请求中 requestUser 为 true 时包含,格式见 §用户信息的序列化) + } +} +``` + +### 绑定第三方账号到当前用户 + +`POST /oauth/bind/{provider}` + +请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回 +`401 Unauthorized`。 + +将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页 + +### 解绑第三方账号 + +`DELETE /oauth/bind/{provider}` + +请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回 +`401 Unauthorized`。 + +若操作成功或从未绑定这个服务商,服务端应返回 HTTP 状态 `204 No Content` + +### 获取所有支持的 OAuth 提供商及当前用户的绑定状态(待定) + +## 密码管理 + +## 其他(待定) \ No newline at end of file diff --git a/pom.xml b/pom.xml index bdae78a..b2d82fb 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.miowing MioVerify - 1.3.0-alpha + commit-bab54412d55042c93f0822fa831c5aedd4e60a47 MioVerify A Minecraft verification server implementing Yggdrasil API @@ -75,8 +75,7 @@ org.springframework.boot - spring-boot-starter-security-oauth2-client - 4.0.3 + spring-boot-starter-oauth2-client diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java index 4f595db..6d9ae82 100644 --- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -1,6 +1,5 @@ package org.miowing.mioverify.config; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp; import org.miowing.mioverify.service.OAuthService; @@ -20,6 +19,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.web.util.UriComponentsBuilder; import java.util.List; import java.util.Map; @@ -102,7 +102,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .clientRegistrationRepository(filteredRepository()) .successHandler(oAuth2SuccessHandler()) // 登录失败 - .failureUrl("/oauth/error") + .failureHandler((request, response, exception) -> { + String frontendErrorUri = dataUtil.getOauthFrontendRedirectUri(); // 可配置 + String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendErrorUri) + .queryParam("error", exception.getMessage()) + .build().toUriString(); + response.setStatus(HttpStatus.FOUND.value()); + response.setHeader("Location", redirectUrl); + }) ); } @@ -146,10 +153,19 @@ private AuthenticationSuccessHandler oAuth2SuccessHandler() { provider, providerUserId, providerUsername ); - response.setContentType("application/json;charset=UTF-8"); - response.setStatus(HttpStatus.OK.value()); - // 直接复用 OAuthCallbackResp,Jackson 序列化 - response.getWriter().write(new ObjectMapper().writeValueAsString(resp)); + String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri(); // 假设有这个方法 + + // 构建重定向 URL,添加必要参数 + String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendRedirectUri) + .queryParam("tempToken", resp.getTempToken()) + .queryParam("provider", provider) + .queryParam("needProfile", resp.isNeedProfile()) + .queryParam("userId", resp.getUserId() != null ? resp.getUserId() : "") + .build().toUriString(); + + // 发送重定向 + response.setStatus(HttpStatus.FOUND.value()); + response.setHeader("Location", redirectUrl); }; } diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java index 5ee0237..2315996 100644 --- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java +++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java @@ -4,7 +4,10 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.dao.UserDao; -import org.miowing.mioverify.exception.*; +import org.miowing.mioverify.exception.FeatureNotSupportedException; +import org.miowing.mioverify.exception.InvalidTokenException; +import org.miowing.mioverify.exception.NoProfileException; +import org.miowing.mioverify.exception.UnauthorizedException; import org.miowing.mioverify.pojo.AToken; import org.miowing.mioverify.pojo.Profile; import org.miowing.mioverify.pojo.ProfileShow; @@ -187,12 +190,4 @@ public ResponseEntity unbindProvider( return new ResponseEntity<>(HttpStatus.NO_CONTENT); } - /** - * OAuth2 登录失败后的跳转端点 - * 由 Spring Security 在 OAuth2 流程失败时自动重定向至此 - */ - @GetMapping("/error") - public void oauthError() { - throw new LoginFailedException(); - } } diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java index 66430a9..1669fab 100644 --- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java @@ -4,8 +4,8 @@ import lombok.experimental.Accessors; /** - * OAuth 回调响应 - * 用于 /oauth/callback/{provider} 接口返回临时令牌和用户信息 + * 并非 OAuth 回调响应 (临时作为数据中转类) + * 用于返回临时令牌和用户信息 */ @Data @Accessors(chain = true) diff --git a/src/main/java/org/miowing/mioverify/util/DataUtil.java b/src/main/java/org/miowing/mioverify/util/DataUtil.java index 05fe3af..0c32cd6 100644 --- a/src/main/java/org/miowing/mioverify/util/DataUtil.java +++ b/src/main/java/org/miowing/mioverify/util/DataUtil.java @@ -76,6 +76,8 @@ public class DataUtil implements InitializingBean { private boolean oAuthCustomEnabled; @Value("${spring.security.oauth2.expire:5m}") private Duration oauthExpire; + @Value("${mioverify.oauth-frontend-redirect-uri}") + private String oauthFrontendRedirectUri; @Override public void afterPropertiesSet() throws Exception { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a78d578..d51ecc3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,34 +33,34 @@ spring: enabled: true client-id: "your-github-client-id" client-secret: "your-github-client-secret" - redirect-uri: "{baseUrl}/oauth/callback/github" + redirect-uri: "{baseUrl}/oauth/callback/github" # 不可修改! scope: read:user, user:email microsoft: enabled: true client-id: "client-id" client-secret: "client-secret" - redirect-uri: "{baseUrl}/oauth/callback/microsoft" + redirect-uri: "{baseUrl}/oauth/callback/microsoft" # 不可修改! scope: openid, profile, email mcjpg: enabled: true client-id: "client-id" client-secret: "client-secret" - redirect-uri: "{baseUrl}/oauth/callback/mcjpg" + redirect-uri: "{baseUrl}/oauth/callback/mcjpg" # 不可修改! scope: openid, profile, email custom: enabled: true client-id: "client-id" client-secret: "client-secret" - redirect-uri: "{baseUrl}/oauth/callback/custom" + redirect-uri: "{baseUrl}/oauth/callback/custom" # 不可修改! scope: read client-name: "custom" provider: microsoft: - issuer-uri: "https://login.microsoftonline.com/your-tenant-id/v2.0" + issuer-uri: "https://login.microsoftonline.com/common/v2.0" user-name-attribute: name mcjpg: issuer-uri: "https://sso.mcjpg.org" @@ -97,6 +97,8 @@ mybatis-plus: mioverify: + # OAuth 前端回调地址 (全部uri均可修改) + oauth-frontend-redirect-uri: "https://your-frontend.com/oauth/callback" # 跨域配置,生产环境中必须设置 cors: diff --git a/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java b/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java index 670f01d..1584794 100644 --- a/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java +++ b/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java @@ -2,10 +2,15 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; @SpringBootTest class MioVerifyApplicationTests { + @MockBean + private ClientRegistrationRepository clientRegistrationRepository; + @Test void contextLoads() { } diff --git a/src/test/resources/application-test-sqlite.yml b/src/test/resources/application-test-sqlite.yml new file mode 100644 index 0000000..c82c25d --- /dev/null +++ b/src/test/resources/application-test-sqlite.yml @@ -0,0 +1,4 @@ +spring: + security: + oauth2: + enabled: false # 禁用 OAuth2 \ No newline at end of file From 2952e5013816d67cbd2c31f73a5a277f263199f4 Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:25:51 +0800 Subject: [PATCH 05/12] wip: Oauth --- OAuthAPI.md | 39 +++++- pom.xml | 2 +- .../mioverify/config/SecurityConfig.java | 130 +++++++++++++++--- .../mioverify/controller/OAuthController.java | 115 +++++++++++++--- .../listener/TokenExpiredListener.java | 6 + .../mioverify/pojo/oauth/OAuthBindReq.java | 19 --- .../pojo/oauth/OAuthProviderListResp.java | 22 ++- .../mioverify/pojo/oauth/OAuthState.java | 14 -- .../pojo/oauth/OauthUserRegisterReq.java | 26 ---- .../mioverify/service/OAuthService.java | 9 ++ .../mioverify/service/RedisService.java | 19 +++ .../service/impl/OAuthServiceImpl.java | 31 ++++- .../service/impl/RedisServiceImpl.java | 15 +- .../java/org/miowing/mioverify/util/Util.java | 5 +- 14 files changed, 342 insertions(+), 110 deletions(-) delete mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java delete mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java delete mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java diff --git a/OAuthAPI.md b/OAuthAPI.md index 7438377..a1a9e33 100644 --- a/OAuthAPI.md +++ b/OAuthAPI.md @@ -6,8 +6,6 @@ 基本上与 **Yggdrasil-服务端技术规范** 保持一致。 -TODO: 异常情况和错误类型表格 - ## OAuth 第三方登录/注册 ### OAuth 状态查询 @@ -44,7 +42,7 @@ TODO: 异常情况和错误类型表格 此端点由 Provider 在用户授权后自动调用,**无需前端直接访问**。 -后端除了完毕后会将浏览器重定向至配置的 `OAuth 前端回调地址`,并在 URL **查询** 参数中附加以下信息: +后端处理完毕后会将浏览器重定向至配置的 `OAuth 前端回调地址`,并在 URL **查询** 参数中附加以下信息: | 参数名 | 类型 | 描述 | |:-------------:|:-------:|:------------------------------------------------------| @@ -94,8 +92,7 @@ TODO: 异常情况和错误类型表格 `POST /oauth/bind/{provider}` -请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回 -`401 Unauthorized`。 +TODO 将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页 @@ -106,9 +103,39 @@ TODO: 异常情况和错误类型表格 请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回 `401 Unauthorized`。 +若用户仅剩一个第三方认证,则解绑失败,返回 `403 Forbidden` + 若操作成功或从未绑定这个服务商,服务端应返回 HTTP 状态 `204 No Content` -### 获取所有支持的 OAuth 提供商及当前用户的绑定状态(待定) +### 获取所有支持的 OAuth 提供商及当前用户的绑定状态 + +`GET /oauth/providers` + +请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回 +`401 Unauthorized`。 + +响应格式: + +```json5 +{ + "providers": [ + { + "provider": "唯一标识符", // 例如 github + "bond": true // true or false 表示是否绑定 + }, + // 可以包含更多 + ] +} +``` + +### 登出 + +`POST /oauth/signout` + +请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回 +`401 Unauthorized`。 + +若操作成功,服务端应返回 HTTP 状态 `204 No Content`。 ## 密码管理 diff --git a/pom.xml b/pom.xml index b2d82fb..68e1bf4 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.miowing MioVerify - commit-bab54412d55042c93f0822fa831c5aedd4e60a47 + commit-9b9a04258456b6223be2d7fc998bd58c03c08875 MioVerify A Minecraft verification server implementing Yggdrasil API diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java index 6d9ae82..142ceeb 100644 --- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp; import org.miowing.mioverify.service.OAuthService; +import org.miowing.mioverify.service.RedisService; import org.miowing.mioverify.util.DataUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -16,9 +17,13 @@ import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.util.UriComponentsBuilder; import java.util.List; @@ -31,7 +36,7 @@ *

本类承担以下职责:

*
    *
  • 禁用 CSRF(项目为无状态 REST API,使用自定义 Token 鉴权)
  • - *
  • 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 自行处理)
  • + *
  • 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 处理)
  • *
  • 根据配置文件中的开关,动态注册启用的 OAuth2 Provider
  • *
  • 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}} 两个端点, * 由 Spring Security 自动完成授权跳转与 code 换 token 流程
  • @@ -54,6 +59,8 @@ public class SecurityConfig { @Autowired private OAuthService oAuthService; @Autowired + private RedisService redisService; + @Autowired private DataUtil dataUtil; @@ -93,6 +100,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 接管 /oauth/authorize/{provider} .authorizationEndpoint(auth -> auth .baseUri("/oauth/authorize") + .authorizationRequestResolver(buildAuthorizationRequestResolver()) ) // 接管 /oauth/callback/{provider} .redirectionEndpoint(redirect -> redirect @@ -144,31 +152,94 @@ private AuthenticationSuccessHandler oAuth2SuccessHandler() { // 不同 Provider 的用户名字段名不同,按优先级取 String providerUserId = oAuth2User.getName(); String providerUsername = resolveUsername(oAuth2User); + String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri(); - log.info("OAuth2 authorization success: provider={}, userId={}, username={}", - provider, providerUserId, providerUsername); + // 从 state 参数里尝试提取 bindNonce + String state = request.getParameter("state"); + String bindNonce = extractBindNonce(state); - // 交给 OAuthService 处理用户查找/创建,返回临时 Token - OAuthCallbackResp resp = oAuthService.handleOAuthLogin( - provider, providerUserId, providerUsername - ); + if ( bindNonce != null ) { + // 绑定流程 + String userId = redisService.consumeOAuthBindNonce(bindNonce); + + if ( userId == null ) { + // nonce 已过期或不存在 + redirect(response, frontendRedirectUri, "error", "bind_nonce_expired"); + return; + } + + try { + oAuthService.handleOAuthBind(userId, provider, providerUserId); + } catch (Exception e) { + log.warn("OAuth bind failed: {}", e.getClass().getSimpleName()); + redirect(response, frontendRedirectUri, "error", "bind_failed"); + return; + } + + log.info("OAuth2 bind success: provider={}, userId={}", provider, userId); + redirect(response, frontendRedirectUri, "bindSuccess", "true"); + + } else { + log.info("OAuth2 authorization success: provider={}, userId={}, username={}", + provider, providerUserId, providerUsername); - String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri(); // 假设有这个方法 + // 交给 OAuthService 处理用户查找/创建,返回临时 Token + OAuthCallbackResp resp = oAuthService.handleOAuthLogin( + provider, providerUserId, providerUsername + ); - // 构建重定向 URL,添加必要参数 - String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendRedirectUri) - .queryParam("tempToken", resp.getTempToken()) - .queryParam("provider", provider) - .queryParam("needProfile", resp.isNeedProfile()) - .queryParam("userId", resp.getUserId() != null ? resp.getUserId() : "") - .build().toUriString(); + // 构建重定向 URL,添加必要参数 + String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendRedirectUri) + .queryParam("tempToken", resp.getTempToken()) + .queryParam("provider", provider) + .queryParam("needProfile", resp.isNeedProfile()) + .queryParam("userId", resp.getUserId() != null ? resp.getUserId() : "") + .build().toUriString(); - // 发送重定向 - response.setStatus(HttpStatus.FOUND.value()); - response.setHeader("Location", redirectUrl); + // 发送重定向 + response.setStatus(HttpStatus.FOUND.value()); + response.setHeader("Location", redirectUrl); + } }; } + + /** + * 自定义授权请求解析器。
    + * 作用:当请求 /oauth/authorize/{provider}?bind_nonce=xxx 时,
    + * 把 bind_nonce 附加到 OAuth state 里,格式为:
    + * "原始state,bindnonce:xxxxx"
    + * successHandler 收到回调后,从 state 里读出 nonce,识别是绑定模式。 + */ + private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver() { + DefaultOAuth2AuthorizationRequestResolver resolver = + new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + "/oauth/authorize" + ); + + resolver.setAuthorizationRequestCustomizer(customizer -> { + // 从当前请求里取 bind_nonce 参数 + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if ( attributes == null ) return; + + jakarta.servlet.http.HttpServletRequest request = + (jakarta.servlet.http.HttpServletRequest) + attributes.resolveReference(RequestAttributes.REFERENCE_REQUEST); + if ( request == null ) return; + + String bindNonce = request.getParameter("bind_nonce"); + if ( bindNonce != null && ! bindNonce.isBlank() ) { + // 附加到 state 末尾,以逗号分隔 + String originalState = customizer.build().getState(); + customizer.state(originalState + ",bindnonce:" + bindNonce); + } + }); + + return resolver; + } + + /** * 根据不同 Provider 解析用户名。 * @@ -221,4 +292,27 @@ private ClientRegistrationRepository filteredRepository() { return new InMemoryClientRegistrationRepository(activeList); } + /** 从 state 中提取 bindNonce,格式:<原始state>,bindnonce: */ + private String extractBindNonce(String state) { + if ( state == null ) return null; + for ( String part : state.split(",") ) { + if ( part.startsWith("bindnonce:") ) { + return part.substring("bindnonce:".length()); + } + } + return null; + } + + /** 工具方法:重定向并带一个查询参数 */ + private void redirect( + jakarta.servlet.http.HttpServletResponse response, + String baseUrl, String key, String value) throws java.io.IOException { + + String url = UriComponentsBuilder.fromHttpUrl(baseUrl) + .queryParam(key, value) + .build().toUriString(); + response.setStatus(HttpStatus.FOUND.value()); + response.setHeader("Location", url); + } + } diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java index 2315996..445cd1f 100644 --- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java +++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java @@ -1,7 +1,7 @@ package org.miowing.mioverify.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import jakarta.servlet.http.HttpServletRequest; +import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.dao.UserDao; import org.miowing.mioverify.exception.FeatureNotSupportedException; @@ -13,7 +13,6 @@ import org.miowing.mioverify.pojo.ProfileShow; import org.miowing.mioverify.pojo.User; import org.miowing.mioverify.pojo.oauth.OAuthAuthReq; -import org.miowing.mioverify.pojo.oauth.OAuthBindReq; import org.miowing.mioverify.pojo.oauth.OAuthProviderListResp; import org.miowing.mioverify.pojo.oauth.OAuthStatusResp; import org.miowing.mioverify.pojo.response.AuthResp; @@ -31,9 +30,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; /** - * OAuth 2.0 / OIDC 相关控制器 + * OAuth 2.0 控制器 * 所有端点均以 /oauth 开头,处理第三方登录、绑定、解绑等操作 */ @Slf4j @@ -46,22 +46,17 @@ public class OAuthController { @Autowired private UserService userService; - @Autowired private ProfileService profileService; - @Autowired private RedisService redisService; - @Autowired private OAuthService oAuthService; @Autowired private DataUtil dataUtil; - @Autowired private TokenUtil tokenUtil; - @Autowired private Util util; @@ -76,7 +71,7 @@ public OAuthStatusResp status() { } List providers = new ArrayList<>(); if ( dataUtil.isOAuthMicrosoftEnabled() ) providers.add("microsoft"); - if ( dataUtil.isOAuthGitHubEnabled() ) providers.add("gitHub"); + if ( dataUtil.isOAuthGitHubEnabled() ) providers.add("github"); if ( dataUtil.isOAuthMcjpgEnabled() ) providers.add("mcjpg"); if ( dataUtil.isOAuthCustomEnabled() ) providers.add("custom"); return new OAuthStatusResp().setEnabled(true).setProviders(providers); @@ -129,36 +124,83 @@ public AuthResp authenticate(@RequestBody OAuthAuthReq req) { * 获取所有支持的 OAuth 提供商及当前用户的绑定状态 */ @GetMapping("/providers") - public OAuthProviderListResp getProviders(HttpServletRequest request) { + public OAuthProviderListResp getProviders(@RequestHeader("Authorization") String authorization) { if ( ! dataUtil.isOAuthEnabled() ) { throw new FeatureNotSupportedException(); } - //TODO + User user = getUserFromAuthorization(authorization); - return null; + List providerInfos = new ArrayList<>(); + + if ( dataUtil.isOAuthMicrosoftEnabled() ) { + providerInfos.add(new OAuthProviderListResp.ProviderInfo() + .setProvider("microsoft") + .setBound(user.getMicrosoftId() != null)); + } + if ( dataUtil.isOAuthGitHubEnabled() ) { + providerInfos.add(new OAuthProviderListResp.ProviderInfo() + .setProvider("github") + .setBound(user.getGithubId() != null)); + } + if ( dataUtil.isOAuthMcjpgEnabled() ) { + providerInfos.add(new OAuthProviderListResp.ProviderInfo() + .setProvider("mcjpg") + .setBound(user.getMcjpgId() != null)); + } + if ( dataUtil.isOAuthCustomEnabled() ) { + providerInfos.add(new OAuthProviderListResp.ProviderInfo() + .setProvider("custom") + .setBound(user.getCustomId() != null)); + } + + return new OAuthProviderListResp().setProviders(providerInfos); } /** * 绑定第三方账号到当前用户(需要登录) + *
    + * 1. 校验用户 Token + * 2. 生成 bindNonce 存入 Redis + * 3. 返回带有 bind_nonce 参数的 OAuth 授权 URL + * 4. 前端拿到 URL 后直接跳转,剩下的全交给 Spring Security */ @PostMapping("/bind/{provider}") public ResponseEntity bindProvider( @PathVariable String provider, - @RequestBody OAuthBindReq req, - HttpServletRequest request) { + @RequestHeader("Authorization") String authorization) { if ( ! dataUtil.isOAuthEnabled() ) { throw new FeatureNotSupportedException(); } - //TODO + boolean providerEnabled = switch ( provider ) { + case "github" -> dataUtil.isOAuthGitHubEnabled(); + case "microsoft" -> dataUtil.isOAuthMicrosoftEnabled(); + case "mcjpg" -> dataUtil.isOAuthMcjpgEnabled(); + case "custom" -> dataUtil.isOAuthCustomEnabled(); + default -> false; + }; + if ( ! providerEnabled ) { + throw new FeatureNotSupportedException(); + } + + User user = getUserFromAuthorization(authorization); + String nonce = Util.genUUID(); + redisService.saveOAuthBindNonce(nonce, user.getId()); - return ResponseEntity.ok().build(); + String serverUrl = (dataUtil.isUseHttps() ? "https://" : "http://") + + dataUtil.getServerDomain() + ":" + dataUtil.getPort(); + String authUrl = serverUrl + "/oauth/authorize/" + provider + + "?bind_nonce=" + nonce; + + log.info("User {} initiate bind for provider: {}", user.getId(), provider); + + return ResponseEntity.ok(Map.of("authorizationUrl", authUrl)); } /** - * 解绑第三方账号 + * 解绑第三方账号(需要登录) */ @DeleteMapping("/bind/{provider}") public ResponseEntity unbindProvider( @@ -168,6 +210,41 @@ public ResponseEntity unbindProvider( throw new FeatureNotSupportedException(); } + User user = getUserFromAuthorization(authorization); + + oAuthService.unbind(user.getId(), provider); // 取消成功和未绑定均返回 204 + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 登出方法 + * //TODO 临时设置,后续更改为更合适的登出方法 + * + * @see AuthController + */ + @Deprecated + @PostMapping("/signout") + public ResponseEntity signOut(@RequestHeader("Authorization") String authorization) { + if ( ! dataUtil.isOAuthEnabled() ) { + throw new FeatureNotSupportedException(); + } + + User user = getUserFromAuthorization(authorization); + redisService.clearToken(user.getId()); + log.info("Oauth logout: {}", user.getUsername()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + + /** + * 从 Authorization 头提供的 accessToken 解析用户 + * + * @param authorization Http 请求 Authorization头 + * + * @return {@link User} 实例 + */ + private @NonNull User getUserFromAuthorization(String authorization) { if ( authorization == null || ! authorization.startsWith("Bearer ") ) { throw new UnauthorizedException(); // 401 } @@ -185,9 +262,7 @@ public ResponseEntity unbindProvider( throw new UnauthorizedException(); // 401 } - oAuthService.unbind(user.getId(), provider); // 取消成功和未绑定均返回 204 - - return new ResponseEntity<>(HttpStatus.NO_CONTENT); + return user; } } diff --git a/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java b/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java index 9acd231..bc4538f 100644 --- a/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java +++ b/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java @@ -10,6 +10,7 @@ import org.springframework.data.redis.listener.KeyExpirationEventMessageListener; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.stereotype.Component; + import java.nio.charset.StandardCharsets; /** @@ -23,14 +24,18 @@ public class TokenExpiredListener extends KeyExpirationEventMessageListener { @Autowired private StringRedisTemplate redisTemplate; + public TokenExpiredListener(RedisMessageListenerContainer listenerContainer) { super(listenerContainer); + // } + @Override public void afterPropertiesSet() throws Exception { log.info("Tokens expired Listener hooked."); super.afterPropertiesSet(); } + @Override protected void doHandleMessage(Message message) { String key = new String(message.getBody(), StandardCharsets.UTF_8); @@ -49,4 +54,5 @@ protected void doHandleMessage(Message message) { } super.doHandleMessage(message); } + } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java deleted file mode 100644 index daeab4f..0000000 --- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.miowing.mioverify.pojo.oauth; - -import lombok.Data; - - -/** - * 手动绑定第三方账号的请求体 - * 用于 POST /oauth/{provider}/bind - */ -@Data -public class OAuthBindReq { - - /** Provider 返回的用户唯一 ID */ - private String providerUserId; - - /** Provider 返回的用户名 */ - private String providerUsername; - -} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java index 6078559..d7e1936 100644 --- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java @@ -1,5 +1,25 @@ package org.miowing.mioverify.pojo.oauth; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +@Data +@Accessors(chain = true) public class OAuthProviderListResp { -} + private List providers; + + @Data + @Accessors(chain = true) + public static class ProviderInfo { + + /** Provider 名称 */ + private String provider; + /** 当前用户是否已绑定该 Provider */ + private boolean bound; + + } + +} \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java deleted file mode 100644 index b499d72..0000000 --- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.miowing.mioverify.pojo.oauth; - -import lombok.Data; -import org.springframework.lang.Nullable; - -@Data -public class OAuthState { - - private String provider; - private boolean bind; // 是否为绑定模式 - private @Nullable String userId; // 绑定模式下的当前用户ID - private String redirectUri; // 登录成功后重定向地址 - -} diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java deleted file mode 100644 index 99a4d13..0000000 --- a/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.miowing.mioverify.pojo.oauth; - -import lombok.Data; -import org.springframework.lang.Nullable; - -@Data -public class OauthUserRegisterReq { - - private String username; - - private @Nullable String password; // 密码可为空 - - private String preferredLang = "zh_CN"; - - private String key; - - // OAuth相关字段 - - private @Nullable String microsoftId; - - private @Nullable String githubId; - - private @Nullable String mcjpgId; - - private @Nullable String customId; -} diff --git a/src/main/java/org/miowing/mioverify/service/OAuthService.java b/src/main/java/org/miowing/mioverify/service/OAuthService.java index 22cb616..4c19526 100644 --- a/src/main/java/org/miowing/mioverify/service/OAuthService.java +++ b/src/main/java/org/miowing/mioverify/service/OAuthService.java @@ -25,4 +25,13 @@ public interface OAuthService { */ void unbind(String userId, String provider); + /** + * 将 OAuth provider 账号绑定到已有本地用户。 + * + * @param userId 要绑定到的本地用户 ID(从 nonce 中取出) + * @param provider provider 名称 + * @param providerUserId provider 返回的用户唯一 ID + */ + void handleOAuthBind(String userId, String provider, String providerUserId); + } diff --git a/src/main/java/org/miowing/mioverify/service/RedisService.java b/src/main/java/org/miowing/mioverify/service/RedisService.java index daeac14..26a0bd7 100644 --- a/src/main/java/org/miowing/mioverify/service/RedisService.java +++ b/src/main/java/org/miowing/mioverify/service/RedisService.java @@ -8,6 +8,7 @@ public interface RedisService { void clearToken(String userId); void saveSession(String serverId, String token); + /** * 保存 OAuth 临时令牌,用于后续换取正式的 accessToken。 * 临时令牌需设置较短的过期时间,防止滥用。 @@ -26,4 +27,22 @@ public interface RedisService { */ String consumeOAuthTempToken(String tempToken); + /** + * 保存 OAuth 绑定 Nonce,用于绑定流程中将 nonce 与 userId 关联。 + * 设置与 oauthExpire 一致的过期时间,超时自动失效。 + * + * @param nonce 随机生成的绑定标识符(UUID) + * @param userId 发起绑定的用户 ID + */ + void saveOAuthBindNonce(String nonce, String userId); + + /** + * 消费 OAuth 绑定 Nonce(一次性原子操作)。 + * 取出对应的用户 ID 后立即删除,防止重放攻击。 + * + * @param nonce 待消费的 nonce + * + * @return 对应的用户 ID;若不存在、已过期或已被消费,则返回 null + */ + String consumeOAuthBindNonce(String nonce); } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java index b7be4dc..562dd93 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java @@ -4,6 +4,7 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.dao.UserDao; +import org.miowing.mioverify.exception.DuplicateUserNameException; import org.miowing.mioverify.exception.FeatureNotSupportedException; import org.miowing.mioverify.pojo.User; import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp; @@ -91,7 +92,6 @@ public OAuthCallbackResp handleOAuthLogin( .setNeedProfile(false); //暂时设置为 false } - @Override public void unbind(@NonNull String userId, @NonNull String provider) { BiConsumer setter = PROVIDER_SETTER.get(provider); @@ -108,9 +108,36 @@ public void unbind(@NonNull String userId, @NonNull String provider) { log.info("User: {} ,Unbind oauth provider: {}", userId, provider); } + @Override + public void handleOAuthBind(String userId, String provider, String providerUserId) { - //----------- + BiConsumer setter = PROVIDER_SETTER.get(provider); + String fieldName = PROVIDER_FIELD.get(provider); + if ( setter == null || fieldName == null ) { + throw new FeatureNotSupportedException(); + } + + // 检查该第三方账号是否已被其他本地账号绑定 + User existing = userDao.selectOne( + new QueryWrapper().eq(fieldName, providerUserId) + ); + if ( existing != null && ! existing.getId().equals(userId) ) { + throw new DuplicateUserNameException(); // 已被其他账号绑定 + } + + User user = userDao.selectById(userId); + if ( user == null ) { + throw new FeatureNotSupportedException(); + } + + setter.accept(user, providerUserId); + userDao.updateById(user); + + log.info("Oauth User {} bind provider {} -> {}", userId, provider, providerUserId); + } + + //----------- /** * 创建新用户 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 ac25ffe..32e5bbe 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java @@ -23,9 +23,8 @@ public class RedisServiceImpl implements RedisService { @Autowired private DataUtil dataUtil; - - private static final String OAUTH_STATE_PREF = "oauth:state:"; private static final String OAUTH_TEMP_TOKEN_PREF = "oauth:temp:"; + private static final String OAUTH_BIND_NONCE_PREF = "oauth:bind:"; @Override public void saveToken(String token, String userId) { @@ -69,6 +68,7 @@ public void saveSession(String serverId, String token) { redisTemplate.opsForValue().set(SessionUtil.SESSION_PREF + serverId, token, dataUtil.getSessionExpire()); } + @Override public void saveOAuthTempToken(String tempToken, String userId) { String key = OAUTH_TEMP_TOKEN_PREF + tempToken; @@ -81,4 +81,15 @@ public String consumeOAuthTempToken(String tempToken) { return redisTemplate.opsForValue().getAndDelete(key); } + @Override + public void saveOAuthBindNonce(String nonce, String userId) { + String key = OAUTH_BIND_NONCE_PREF + nonce; + redisTemplate.opsForValue().set(key, userId, dataUtil.getOauthExpire().getSeconds(), TimeUnit.SECONDS); + } + + @Override + public String consumeOAuthBindNonce(String nonce) { + return redisTemplate.opsForValue().getAndDelete(OAUTH_BIND_NONCE_PREF + nonce); + } + } \ No newline at end of file diff --git a/src/main/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java index 4e31cc4..925f32f 100644 --- a/src/main/java/org/miowing/mioverify/util/Util.java +++ b/src/main/java/org/miowing/mioverify/util/Util.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; + import java.io.IOException; import java.security.*; import java.util.Base64; @@ -51,7 +52,9 @@ public String getTextureURL(String hash) { return getServerURL() + "/texture/hash/" + hash; } public String getServerURL() { - return dataUtil.isUseHttps() ? "https://" : "http://" + dataUtil.getServerDomain() + ":" + dataUtil.getPort(); + //TODO 需要优化CDN + return (dataUtil.isUseHttps() ? "https://" : "http://") + + dataUtil.getServerDomain() + ":" + dataUtil.getPort(); } public String signature(String value) { try { From 0e3997a924de0816ce0cf04c3dd2a0327ef928ed Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:53:06 +0800 Subject: [PATCH 06/12] =?UTF-8?q?perf:=20URL=20=E6=94=B9=E4=B8=BA=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/miowing/mioverify/util/Util.java | 6 +++--- src/main/resources/application.yml | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java index 925f32f..b77cf46 100644 --- a/src/main/java/org/miowing/mioverify/util/Util.java +++ b/src/main/java/org/miowing/mioverify/util/Util.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.IOException; import java.security.*; @@ -52,9 +53,8 @@ public String getTextureURL(String hash) { return getServerURL() + "/texture/hash/" + hash; } public String getServerURL() { - //TODO 需要优化CDN - return (dataUtil.isUseHttps() ? "https://" : "http://") - + dataUtil.getServerDomain() + ":" + dataUtil.getPort(); + // TODO + return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); } public String signature(String value) { try { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d51ecc3..bce325d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,6 +17,8 @@ server: key-alias: boot servlet: application-display-name: MioVerify Alpha + # 自动适配 Nginx 和 Cloudflare + forward-headers-strategy: framework spring: From 0bcfa8221c049a599ba29e1eba10ed17108b39ad Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:56:50 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=E5=BE=AE=E8=BD=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E9=87=8D=E5=A4=8D=E8=BF=9B=E8=A1=8C=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=20style:=20TODO=20=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=8E=BB=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mioverify/config/SecurityConfig.java | 171 +++++++++++------- .../java/org/miowing/mioverify/util/Util.java | 1 - .../resources/application-test-sqlite.yml | 4 - 3 files changed, 105 insertions(+), 71 deletions(-) diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java index 142ceeb..aba9daf 100644 --- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -6,6 +6,8 @@ import org.miowing.mioverify.service.RedisService; import org.miowing.mioverify.util.DataUtil; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; @@ -28,7 +30,6 @@ import java.util.List; import java.util.Map; -import java.util.Objects; /** *

    Spring Security 全局安全配置类

    @@ -36,26 +37,37 @@ *

    本类承担以下职责:

    *
      *
    • 禁用 CSRF(项目为无状态 REST API,使用自定义 Token 鉴权)
    • - *
    • 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 处理)
    • - *
    • 根据配置文件中的开关,动态注册启用的 OAuth2 Provider
    • - *
    • 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}} 两个端点, - * 由 Spring Security 自动完成授权跳转与 code 换 token 流程
    • + *
    • 放行所有接口
    • + *
    • 根据配置文件中各 Provider 的 {@code enabled} 开关,在 Spring 容器启动阶段 + * 提前过滤,仅将已启用的 Provider 注册进 + * {@link ClientRegistrationRepository},避免 Spring Boot 自动配置在启动时 + * 请求所有 Provider 的 {@code issuer-uri}(主要是微软)导致启动失败
    • + *
    • 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}} + * 两个端点,由 Spring Security 自动完成授权跳转与 code 换 token 流程
    • *
    • OAuth2 登录成功后,通过 {@code successHandler} 调用业务层完成用户查找/创建并签发临时 Token
    • *
    * + *

    与原有账密登录的关系

    *

    与原有账密登录({@code /authserver/**})完全独立,互不影响。

    + * + *

    启动顺序说明

    + *
      + *
    1. {@link #clientRegistrationRepository} Bean 在容器启动时被创建, + * 仅包含 {@code enabled=true} 的 Provider
    2. + *
    3. Spring Boot 的 {@code OAuth2ClientRegistrationRepositoryConfiguration} + * 自动配置因检测到已存在同类型 Bean 而跳过,不会再尝试解析任何 Provider 的 (主要是微软) + * {@code issuer-uri}
    4. + *
    5. {@link #filterChain} 注入 Repository
    6. + *
    */ @Configuration @EnableWebSecurity @Slf4j public class SecurityConfig { - /** - * Spring 自动装配的 ClientRegistrationRepository。 - * 包含配置文件中 Provider 的注册信息(无论是否启用)。 - */ + /** OAuth2 客户端配置,从 {@code application.yml} 自动绑定。包含所有 Provider 的原始配置(无论是否启用)。 */ @Autowired - private ClientRegistrationRepository clientRegistrationRepository; + private OAuth2ClientProperties oAuth2ClientProperties; @Autowired private OAuthService oAuthService; @Autowired @@ -63,6 +75,57 @@ public class SecurityConfig { @Autowired private DataUtil dataUtil; + /** + * 创建仅包含已启用 Provider 的 {@link ClientRegistrationRepository} Bean。 + * + *

    避免启动时因请求无效的 {@code issuer-uri} 而抛出异常。

    + * + *
      + *
    1. 读取 {@link DataUtil} 中各 Provider 的 {@code enabled} 开关
    2. + *
    3. 从 {@link OAuth2ClientProperties} 中移除未启用 Provider 的配置
    4. + *
    5. 用过滤后的配置构建 {@link InMemoryClientRegistrationRepository}
    6. + *
    + * + * @return 仅包含已启用 Provider 的 {@link ClientRegistrationRepository} + */ + @Bean + public ClientRegistrationRepository clientRegistrationRepository() { + // OAuth 关闭,直接返回 null + if ( ! dataUtil.isOAuthEnabled() ) { + log.info("OAuth2 is disabled, skipping all provider registration."); + return registrationId -> null; + } + + Map enabledMap = Map.of( + "github", dataUtil.isOAuthGitHubEnabled(), + "microsoft", dataUtil.isOAuthMicrosoftEnabled(), + "mcjpg", dataUtil.isOAuthMcjpgEnabled(), + "custom", dataUtil.isOAuthCustomEnabled() + ); + + // 从原始配置中移除未启用的 Provider,避免触发 issuer-uri 解析 + enabledMap.forEach((id, enabled) -> { + if ( ! enabled ) { + oAuth2ClientProperties.getRegistration().remove(id); + oAuth2ClientProperties.getProvider().remove(id); + log.info("OAuth2 Provider disabled, skipped: {}", id); + } + }); + + List activeList = new OAuth2ClientPropertiesMapper(oAuth2ClientProperties) + .asClientRegistrations() + .values() + .stream() + .peek(r -> log.info("OAuth2 Provider enabled: {}", r.getRegistrationId())) + .toList(); + + if ( activeList.isEmpty() ) { + log.warn("OAuth2 is enabled but no provider is enabled!"); + return registrationId -> null; + } + + return new InMemoryClientRegistrationRepository(activeList); + } /** * 配置 Spring Security 过滤链。 @@ -75,14 +138,14 @@ public class SecurityConfig { *
  • 若 OAuth 总开关开启,注册 OAuth2 登录流程
  • * * - * @param http Spring Security 的 HttpSecurity 构造器 - * + * @param http Spring Security 的 HttpSecurity 构造器 + * @param clientRegistrationRepository 已过滤的 Provider 注册仓库,由 {@link #clientRegistrationRepository()} 提供 * @return 构建完成的 SecurityFilterChain * * @throws Exception 配置异常 */ @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception { http .csrf(AbstractHttpConfigurer :: disable) @@ -100,14 +163,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 接管 /oauth/authorize/{provider} .authorizationEndpoint(auth -> auth .baseUri("/oauth/authorize") - .authorizationRequestResolver(buildAuthorizationRequestResolver()) + .authorizationRequestResolver(buildAuthorizationRequestResolver(clientRegistrationRepository)) ) // 接管 /oauth/callback/{provider} .redirectionEndpoint(redirect -> redirect .baseUri("/oauth/callback/*") ) // 只注册配置文件中 enabled=true 的 Provider - .clientRegistrationRepository(filteredRepository()) + .clientRegistrationRepository(clientRegistrationRepository) .successHandler(oAuth2SuccessHandler()) // 登录失败 .failureHandler((request, response, exception) -> { @@ -130,18 +193,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { *

    当 Spring Security 完成以下工作后,本处理器被调用:

    *
      *
    • 用 code 向 Provider 换取 Access Token
    • - *
    • 用 Access Token ( OAuth 的 Token ) 调用 UserInfo 端点获取用户信息
    • + *
    • 用 Access Token 调用 UserInfo 端点获取用户信息
    • *
    * *

    本处理器负责:

    *
      - *
    • 从 {@link OAuth2User} 中提取 providerUserId 和 providerUsername
    • - *
    • 调用 {@link org.miowing.mioverify.service.OAuthService#handleOAuthLogin} 完成用户查找或创建
    • - *
    • 签发临时 Token,返回给前端
    • - *
    • 前端用临时 Token 调用 {@code POST /oauth/authenticate} 换取正式 accessToken
    • + *
    • 从 {@link OAuth2User} 中提取 {@code providerUserId} 和 {@code providerUsername}
    • + *
    • 从 {@code state} 参数中识别是否为账号绑定流程(携带 {@code bindnonce})
    • + *
    • 绑定流程:校验 nonce 有效性,调用 {@link OAuthService#handleOAuthBind} 完成绑定
    • + *
    • 登录流程:调用 {@link OAuthService#handleOAuthLogin} 完成用户查找或创建, + * 签发临时 Token 并重定向前端
    • *
    * - * @return {@link AuthenticationSuccessHandler} 实例 + * @return {@link AuthenticationSuccessHandler} 实例 */ private AuthenticationSuccessHandler oAuth2SuccessHandler() { return (request, response, authentication) -> { @@ -205,13 +269,19 @@ private AuthenticationSuccessHandler oAuth2SuccessHandler() { /** - * 自定义授权请求解析器。
    - * 作用:当请求 /oauth/authorize/{provider}?bind_nonce=xxx 时,
    - * 把 bind_nonce 附加到 OAuth state 里,格式为:
    - * "原始state,bindnonce:xxxxx"
    - * successHandler 收到回调后,从 state 里读出 nonce,识别是绑定模式。 + * 构建自定义授权请求解析器。 + * + *

    当请求 {@code /oauth/authorize/{provider}?bind_nonce=xxx} 时, + * 将 {@code bind_nonce} 附加到 OAuth {@code state} 参数中,格式为:

    + *
    {@code 原始state,bindnonce:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
    + * + *

    {@link #oAuth2SuccessHandler()} 收到回调后,通过 {@link #extractBindNonce} + * 从 {@code state} 中解析出 nonce,识别当前是绑定模式而非登录模式。

    + * + * @param clientRegistrationRepository 已过滤的 Provider 注册仓库 + * @return 配置完成的 {@link OAuth2AuthorizationRequestResolver} */ - private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver() { + private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) { DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver( clientRegistrationRepository, @@ -243,14 +313,15 @@ private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver() { /** * 根据不同 Provider 解析用户名。 * - *
      - *
    • GitHub:使用 {@code login} 字段
    • - *
    • OIDC(mcjpg / microsoft):使用 {@code preferred_username} 或 {@code name}
    • - *
    + *

    各 Provider 返回的用户名字段不同,按以下优先级依次尝试:

    + *
      + *
    1. {@code login}:GitHub 专用字段
    2. + *
    3. {@code displayName}:MCJPG 专用字段
    4. + *
    5. {@code name}:Microsoft / 其他 OIDC Provider 的通用字段
    6. + *
    * * @param oAuth2User Spring Security 封装的 OAuth2 用户对象 - * - * @return 解析到的用户名,可能为 null + * @return 解析到的用户名,若所有字段均为空则返回 {@code null} */ private String resolveUsername(OAuth2User oAuth2User) { if ( oAuth2User.getAttribute("login") != null ) @@ -260,39 +331,7 @@ private String resolveUsername(OAuth2User oAuth2User) { return oAuth2User.getAttribute("name"); // Microsoft / 其他 } - /** - * 根据配置文件中各 Provider 的 {@code enabled} 开关, - * 过滤出实际启用的 Provider,构建新的 {@link ClientRegistrationRepository}。 - * - *

    未启用的 Provider 不会参与 OAuth2 流程, - * 即使配置文件中写了 client-id / client-secret 也不会生效。

    - * - * @return 仅包含已启用 Provider 的 ClientRegistrationRepository - */ - private ClientRegistrationRepository filteredRepository() { - Map enabledMap = Map.of( - "github", dataUtil.isOAuthGitHubEnabled(), - "microsoft", dataUtil.isOAuthMicrosoftEnabled(), - "mcjpg", dataUtil.isOAuthMcjpgEnabled(), - "custom", dataUtil.isOAuthCustomEnabled() - ); - - List activeList = enabledMap.entrySet().stream() - .filter(Map.Entry :: getValue) - .map(e -> clientRegistrationRepository.findByRegistrationId(e.getKey())) - .filter(Objects :: nonNull) - .peek(r -> log.info("OAuth2 Provider enabled: {}", r.getRegistrationId())) - .toList(); - - if ( activeList.isEmpty() ) { - log.warn("OAuth2 enabled but no provider is enabled!"); - throw new IllegalStateException("OAuth2 enabled but no provider is enabled!"); - } - - return new InMemoryClientRegistrationRepository(activeList); - } - - /** 从 state 中提取 bindNonce,格式:<原始state>,bindnonce: */ + /** 从 state 中提取 bindNonce,格式:
    {@code 原始state,bindnonce:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
    */ private String extractBindNonce(String state) { if ( state == null ) return null; for ( String part : state.split(",") ) { diff --git a/src/main/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java index b77cf46..8628aea 100644 --- a/src/main/java/org/miowing/mioverify/util/Util.java +++ b/src/main/java/org/miowing/mioverify/util/Util.java @@ -53,7 +53,6 @@ public String getTextureURL(String hash) { return getServerURL() + "/texture/hash/" + hash; } public String getServerURL() { - // TODO return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); } public String signature(String value) { diff --git a/src/test/resources/application-test-sqlite.yml b/src/test/resources/application-test-sqlite.yml index c82c25d..e69de29 100644 --- a/src/test/resources/application-test-sqlite.yml +++ b/src/test/resources/application-test-sqlite.yml @@ -1,4 +0,0 @@ -spring: - security: - oauth2: - enabled: false # 禁用 OAuth2 \ No newline at end of file From 52a584703be3387d90f969746b185fe1e1780d63 Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:45:06 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=E5=A4=9A=E5=A4=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++ pom.xml | 2 +- .../mioverify/config/SecurityConfig.java | 2 +- .../mioverify/controller/OAuthController.java | 25 +++++-- .../mioverify/pojo/oauth/OAuthAuthReq.java | 2 +- .../service/impl/OAuthServiceImpl.java | 70 ++++++++++++++++--- src/main/resources/application.yml | 4 +- 7 files changed, 89 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 549e00a..3855bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ build/ ### VS Code ### .vscode/ + +### TEST ### +data.db +logs +keys \ No newline at end of file diff --git a/pom.xml b/pom.xml index 68e1bf4..ec361f3 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.miowing MioVerify - commit-9b9a04258456b6223be2d7fc998bd58c03c08875 + commit-0bcfa8221c049a599ba29e1eba10ed17108b39ad MioVerify A Minecraft verification server implementing Yggdrasil API diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java index aba9daf..1cbb8d7 100644 --- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -150,7 +150,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepo .csrf(AbstractHttpConfigurer :: disable) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) .authorizeHttpRequests(auth -> auth diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java index 445cd1f..f641045 100644 --- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java +++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java @@ -1,6 +1,5 @@ package org.miowing.mioverify.controller; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.miowing.mioverify.dao.UserDao; @@ -27,6 +26,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponentsBuilder; import java.util.ArrayList; import java.util.List; @@ -117,7 +118,7 @@ public AuthResp authenticate(@RequestBody OAuthAuthReq req) { .setClientToken(cToken) .setAvailableProfiles(profiles) .setSelectedProfile(bindProfile) - .setUser(req.getRequestUser() ? util.userToShow(user) : null); + .setUser(req.isRequestUser() ? util.userToShow(user) : null); } /** @@ -189,10 +190,14 @@ public ResponseEntity bindProvider( String nonce = Util.genUUID(); redisService.saveOAuthBindNonce(nonce, user.getId()); - String serverUrl = (dataUtil.isUseHttps() ? "https://" : "http://") - + dataUtil.getServerDomain() + ":" + dataUtil.getPort(); - String authUrl = serverUrl + "/oauth/authorize/" + provider - + "?bind_nonce=" + nonce; + String serverUrl = ServletUriComponentsBuilder + .fromCurrentContextPath() + .toUriString(); + + String authUrl = UriComponentsBuilder.fromHttpUrl(serverUrl) + .pathSegment("oauth", "authorize", provider) + .queryParam("bind_nonce", nonce) + .toUriString(); log.info("User {} initiate bind for provider: {}", user.getId(), provider); @@ -256,7 +261,13 @@ public ResponseEntity signOut(@RequestHeader("Authorization") String authoriz throw new UnauthorizedException(); // 401 } - User user = userDao.selectOne(new LambdaQueryWrapper().eq(User :: getUsername, aToken.name())); + // 通过绑定的 Profile ID 获取用户 ID,避免用户名重复 + Profile profile = profileService.getById(aToken.bindProfile()); + if ( profile == null ) { + throw new UnauthorizedException(); // 401 + } + + User user = userDao.selectById(profile.getBindUser()); if ( user == null ) { throw new UnauthorizedException(); // 401 diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java index 9ba9467..9c46b32 100644 --- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java +++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java @@ -17,6 +17,6 @@ public class OAuthAuthReq { private @Nullable String clientToken; /** 是否在响应中返回用户信息 */ - private Boolean requestUser; + private boolean requestUser; } diff --git a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java index 562dd93..b163472 100644 --- a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java +++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java @@ -67,7 +67,7 @@ public OAuthCallbackResp handleOAuthLogin( BiConsumer setter = PROVIDER_SETTER.get(provider); String fieldName = PROVIDER_FIELD.get(provider); if ( setter == null || fieldName == null ) { - throw new FeatureNotSupportedException(); //不支持的 OAuth 提供商 + throw new FeatureNotSupportedException(); // 不支持的 OAuth 提供商 } if ( providerUserId.isBlank() ) { throw new IllegalArgumentException(); // 提供商用户 ID 不能为空 @@ -77,10 +77,14 @@ public OAuthCallbackResp handleOAuthLogin( new QueryWrapper().eq(fieldName, providerUserId) ); - if ( user == null ) { + boolean isNewUser = (user == null); + if ( isNewUser ) { user = createUser(provider, providerUserId, providerUsername, setter); } + boolean hasProfile = ! profileService.getByUserId(user.getId()).isEmpty(); + boolean needProfile = isNewUser || ! hasProfile; + // 生成临时令牌并存入 Redis String tempToken = Util.genUUID(); redisService.saveOAuthTempToken(tempToken, user.getId()); @@ -89,18 +93,32 @@ public OAuthCallbackResp handleOAuthLogin( .setTempToken(tempToken) .setProvider(provider) .setProviderUsername(providerUsername) - .setNeedProfile(false); //暂时设置为 false + .setNeedProfile(needProfile) + .setUserId(needProfile ? user.getId() : null); // 需要创建 Profile 时返回 userId } @Override public void unbind(@NonNull String userId, @NonNull String provider) { BiConsumer setter = PROVIDER_SETTER.get(provider); if ( setter == null ) { - throw new FeatureNotSupportedException();//不支持的 OAuth 提供商 + throw new FeatureNotSupportedException(); // 不支持的 OAuth 提供商 } User user = userDao.selectById(userId); + if ( user == null ) { + throw new FeatureNotSupportedException(); // 用户不存在 + } + + // 检查是否为唯一认证方式(有密码或有其他绑定的 Provider) + boolean hasPassword = user.getPassword() != null && ! user.getPassword().isBlank(); + int boundProviderCount = countBoundProviders(user); + + // 如果没有密码且只有一个绑定的 Provider,则不允许解绑 + if ( ! hasPassword && boundProviderCount <= 1 ) { + throw new FeatureNotSupportedException(); // 无法解绑唯一的认证方式 + } + // 清空对应的 provider ID setter.accept(user, null); userDao.updateById(user); @@ -139,25 +157,59 @@ public void handleOAuthBind(String userId, String provider, String providerUserI //----------- - /** - * 创建新用户 - */ + /** 创建新用户 */ private User createUser(String provider, String providerUserId, String providerUsername, BiConsumer setter) { User newUser = new User(); newUser.setId(Util.genUUID()); // 使用工具类生成 ID + // 用户名处理:优先使用提供商返回的用户名,否则生成默认名 - String username = (providerUsername != null && ! providerUsername.isBlank()) + String baseUsername = (providerUsername != null && ! providerUsername.isBlank()) ? providerUsername : "user_" + providerUserId.substring(0, Math.min(8, providerUserId.length())); + + // 检查用户名是否已存在,如存在则添加随机后缀 + String username = ensureUniqueUsername(baseUsername); + newUser.setUsername(username); + // 设置对应的 provider ID setter.accept(newUser, providerUserId); - log.info("New user register: {}", newUser.getUsername()); + log.info("New Oauth user register: {}", newUser.getUsername()); userService.save(newUser); return newUser; } + /** 统计用户已绑定的 Provider 数量 */ + private int countBoundProviders(User user) { + int count = 0; + if ( user.getGithubId() != null && ! user.getGithubId().isBlank() ) count++; + if ( user.getMicrosoftId() != null && ! user.getMicrosoftId().isBlank() ) count++; + if ( user.getMcjpgId() != null && ! user.getMcjpgId().isBlank() ) count++; + if ( user.getCustomId() != null && ! user.getCustomId().isBlank() ) count++; + return count; + } + + /** 确保用户名唯一,如果已存在则添加随机后缀 */ + private String ensureUniqueUsername(String baseUsername) { + String username = baseUsername; + int maxAttempts = 10; + + for ( int i = 0; i < maxAttempts; i++ ) { + User existing = userDao.selectOne( + new QueryWrapper().eq("username", username) + ); + if ( existing == null ) { + return username; // 用户名可用 + } + // 用户名已存在,添加随机后缀 + username = baseUsername + "_" + Util.genUUID().substring(0, 6); + } + + // 极端情况:多次尝试后仍冲突,使用 UUID + return baseUsername + "_" + Util.genUUID(); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bce325d..ed15a7c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,8 +17,8 @@ server: key-alias: boot servlet: application-display-name: MioVerify Alpha - # 自动适配 Nginx 和 Cloudflare - forward-headers-strategy: framework + # 自动适配 Nginx 和 Cloudflare,如果你使用了这两个东西,请设为 native 或 framework(更建议) 。而不是 none + forward-headers-strategy: none spring: From 90ff2aebea084503eb643348ed949dc9f897b602 Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:00:23 +0800 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- ...64\346\227\266\346\226\207\344\273\266.md" | 248 ++++++++++++++++++ 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 "\344\270\264\346\227\266\346\226\207\344\273\266.md" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ed15a7c..7adc960 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -99,7 +99,7 @@ mybatis-plus: mioverify: - # OAuth 前端回调地址 (全部uri均可修改) + # OAuth 前端回调地址 (路径不可修改) oauth-frontend-redirect-uri: "https://your-frontend.com/oauth/callback" # 跨域配置,生产环境中必须设置 diff --git "a/\344\270\264\346\227\266\346\226\207\344\273\266.md" "b/\344\270\264\346\227\266\346\226\207\344\273\266.md" new file mode 100644 index 0000000..f5cc24b --- /dev/null +++ "b/\344\270\264\346\227\266\346\226\207\344\273\266.md" @@ -0,0 +1,248 @@ +# MioVerify 部署配置检查清单 + +> 本文档面向首次部署 MioVerify 的用户。请按顺序逐项检查,带 🔴 的为 **必须修改** 项,带 🟡 的为 **按需修改** 项。 + +--- + +## 一、数据库选择 + +| 配置项 | 位置 | 说明 | +|-----------------------------|-----|----------------------------------------------------| +| 🔴 `spring.profiles.active` | 主配置 | 选择数据库类型:`test-sqlite`(轻量/单机)或 `test-mysql`(推荐生产环境) | + +### 如果选择 MySQL + +在配置文件底部 `test-mysql` 段修改: + +| 配置项 | 要改成什么 | 示例 | +|---------------|------------|-----------------------------------------| +| 🔴 `url` | 你的数据库地址和库名 | `jdbc:mysql://localhost:3306/mioverify` | +| 🔴 `username` | 数据库用户名 | `mioverify` | +| 🔴 `password` | 数据库密码 | 一个强密码 | + +> 💡 使用前需先手动创建好数据库(`CREATE DATABASE mioverify;`) + +### 如果选择 SQLite + +无需额外配置,数据会自动保存在项目目录的 `data.db` 文件中。 + +--- + +## 二、Redis 配置 + +| 配置项 | 位置 | 要改成什么 | +|-----------------------------|-----|-----------------------------| +| 🟡 `spring.data.redis.host` | 主配置 | Redis 服务器地址(默认 `localhost`) | +| 🟡 `spring.data.redis.port` | 主配置 | Redis 端口(默认 `6379`) | + +> ⚠️ Redis 是 **必须运行** 的依赖。部署前请确保 Redis 已安装并启动。 +> +> 如果 Redis 有密码或在远程服务器,改用 URL 模式: +> ```yaml +> spring.data.redis.url: redis://:你的密码@地址:端口 +> ``` + +--- + +## 三、安全相关 + +| 配置项 | 默认值 | 要做什么 | +|---------------------------------------------------|------------------|-----------------| +| 🔴 `mioverify.token.signature` | `abcd273nsi179a` | 替换为一段 **随机字符串** | +| 🔴 `mioverify.extern.register.permission-key.key` | `admin123098` | 替换为你自己的管理密钥 | + +> 💡 生成随机字符串的方法(Linux/Mac 终端): +> ```bash +> openssl rand -hex 32 +> ``` + +--- + +## 四、域名与网络 + +### 4.1 服务器域名(必须修改) + +| 配置项 | 默认值 | 要改成什么 | +|------------------------------------|-------------|-----------------------------| +| 🔴 `mioverify.props.server-domain` | `localhost` | 你的实际域名,如 `auth.example.com` | +| 🔴 `mioverify.props.use-https` | `false` | 如果使用 HTTPS 则改为 `true` | +| 🔴 `mioverify.props.skin-domains` | `baidu.com` | 改为你的域名,如 `example.com` | + +> 💡 `skin-domains` 是 MC 客户端加载皮肤时的域名白名单,必须包含你的域名,否则皮肤不显示。 + +### 4.2 端口 + +| 配置项 | 默认值 | 说明 | +|------------------|--------|--------------| +| 🟡 `server.port` | `8080` | 服务监听端口,可按需修改 | + +### 4.3 反向代理( Nginx / Cloudflare ) + +| 配置项 | 默认值 | 什么时候改 | +|--------------------------------------|--------|---------------------------------------------| +| 🟡 `server.forward-headers-strategy` | `none` | 使用了 Nginx 或 Cloudflare **必须**改为 `framework` | + +> ⚠️ 如果你用了反向代理但没改这个值,会导致: +> - OAuth 回调地址错误 +> - 获取到的用户 IP 是代理服务器的 IP +> - HTTPS 判断失败 + +--- + +## 五、SSL / HTTPS + +### 方案 A:用 Nginx 反代处理 SSL(✅ 推荐) + +保持以下配置不变即可: + +```yaml +server.ssl.enabled: false +server.forward-headers-strategy: framework # 记得改这个! +mioverify.props.use-https: true # 改为 true +``` + +> Nginx 负责 SSL 终止,后端只跑 HTTP。 + +### 方案 B:让 MioVerify 自己处理 SSL + +| 配置项 | 要改成什么 | +|------------------------------------|---------------------------| +| 🟡 `server.ssl.enabled` | `true` | +| 🟡 `server.ssl.http-port` | HTTP 入口端口(会自动跳转 HTTPS) | +| 🟡 `server.ssl.key-store` | 你的证书文件路径 | +| 🟡 `server.ssl.key-store-password` | 证书密码(**不要用默认的 `123456`**) | +| 🟡 `server.ssl.key-store-type` | `jks` 或 `PKCS12` | +| 🟡 `server.ssl.key-alias` | 证书别名 | + +--- + +## 六、OAuth2 第三方登录 + +> 如果你不需要某个登录方式,把对应的 `enabled` 设为 `false` 即可跳过。 + +### 6.1 GitHub + +| 配置项 | 要做什么 | +|--------------------|---------------------------------------------------------------------------------------| +| 🟡 `enabled` | 是否启用 | +| 🔴 `client-id` | 去 [GitHub Developer Settings](https://github.com/settings/developers) 创建 OAuth App 获取 | +| 🔴 `client-secret` | 同上 | + +> 创建 GitHub OAuth App 时,回调地址填:`https://你的域名/oauth/callback/github` + +### 6.2 Microsoft + +| 配置项 | 要做什么 | +|--------------------|---------------------------------------------------------------------------------------| +| 🟡 `enabled` | 是否启用 | +| 🔴 `client-id` | 去 [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) 注册应用获取 | +| 🔴 `client-secret` | 同上 | + +> 回调地址填:`https://你的域名/oauth/callback/microsoft` + +### 6.3 mcjpg + +| 配置项 | 要做什么 | +|--------------------|----------------------------------------------| +| 🟡 `enabled` | 是否启用 | +| 🔴 `client-id` | 在 [mcjpg SSO](https://sso.mcjpg.org/) 平台注册获取 | +| 🔴 `client-secret` | 同上 | + +### 6.4 自定义 Provider + +| 配置项 | 要做什么 | +|----------------------------------------|----------------| +| 🟡 `enabled` | 不使用请设为 `false` | +| 🔴 `client-id` / `client-secret` | 对应平台获取 | +| 🔴 `provider.custom.authorization-uri` | 授权端点 URL | +| 🔴 `provider.custom.token-uri` | Token 端点 URL | +| 🔴 `provider.custom.user-info-uri` | 用户信息端点 URL | +| 🟡 `client-name` | 显示名称 | + +### 6.5 OAuth 前端回调 + +| 配置项 | 默认值 | 要改成什么 | +|--------------------------------------------|--------------------------------------------|-------------| +| 🔴 `mioverify.oauth-frontend-redirect-uri` | `https://your-frontend.com/oauth/callback` | 你的前端实际回调页地址 | + +--- + +## 七、跨域配置(CORS) + +> 仅在 **前后端域名不同** 时需要配置。注意 `api.example.com` 和 `login.example.com` 不是同一个域名。 + +| 配置项 | 什么时候改 | 改成什么 | +|------------------------------------|----------|-----------------------------------| +| 🟡 `mioverify.cors.enabled` | 前后端不同域名时 | `true` | +| 🟡 `mioverify.cors.allowed-origin` | 同上 | 前端域名,如 `https://skin.example.com` | + +> ⚠️ 生产环境 **不要** 设为 `*`,会有安全风险。 + +--- + +## 八、注册 API + +| 配置项 | 默认值 | 说明 | +|---------------------------------------------|---------|-----------------------------| +| 🟡 `extern.register.enabled` | `false` | 是否开放注册(不开则只能后台添加用户) | +| 🟡 `extern.register.allow-user` | `true` | 允许注册账号 | +| 🟡 `extern.register.allow-profile` | `false` | 允许注册游戏角色 | +| 🟡 `extern.register.profile-strict` | `true` | 注册角色时要求验证密码(建议保持 `true`) | +| 🟡 `extern.register.permission-key.enabled` | `false` | 是否需要密钥才能注册(**开放注册时强烈建议开启**) | +| 🟡 `mioverify.extern.multi-profile-name` | `true` | 是否允许不同用户取相同角色名 | + +--- + +## 九、Token 与 Session + +| 配置项 | 默认值 | 说明 | +|------------------------------------|-------|-----------------| +| 🟡 `mioverify.token.expire` | `10m` | Token 暂时失效时间 | +| 🟡 `mioverify.token.invalid` | `1h` | Token 过期时间 | +| 🟡 `mioverify.session.expire` | `6m` | Session 过期时间 | +| 🟡 `spring.security.oauth2.expire` | `5m` | OAuth State 有效期 | + +> 💡 部署时不建议保持默认。 +> Minecraft 原版暂时失效为 24 小时 + +--- + +## 十、材质( 皮肤 / 披风 ) + +| 配置项 | 默认值 | 说明 | +|---------------------------------------------|-----------------------------|------------| +| 🟡 `mioverify.texture.storage-loc` | `textures` | 材质文件存储目录 | +| 🟡 `mioverify.texture.default-skin-loc` | `textures/skin/default.png` | 默认皮肤图片路径 | +| 🟡 `spring.servlet.multipart.max-file-size` | `800KB` | 上传皮肤最大文件大小 | + +> 💡 需要提前准备一张默认皮肤 PNG 文件放到对应路径。 + +--- + +## 十一、服务器元数据 + +| 配置项 | 默认值 | 要改成什么 | +|---------------------------------------------|-------------------------|------------------| +| 🟡 `props.meta.server-name` | `MioVerify 验证服务器` | 你的服务器名称(显示在启动器中) | +| 🟡 `props.meta.links.home-page` | `https://www.baidu.com` | 你的主页地址 | +| 🟡 `props.meta.links.register` | `https://www.baidu.com` | 你的注册页地址 | +| 🟡 `mioverify.security.profile-batch-limit` | `5` | 批量查询角色上限 | + +--- + +## 部署前最终自检 + +请确认以下所有项都已完成: + +- [ ] 选好了数据库并填写了连接信息 +- [ ] Redis 已安装并正常运行 +- [ ] `token.signature` 已替换为随机字符串 +- [ ] `permission-key.key` 已替换 +- [ ] `server-domain` 已改为实际域名 +- [ ] `skin-domains` 已改为实际域名 +- [ ] `use-https` 根据实际情况设置 +- [ ] 使用反向代理时 `forward-headers-strategy` 已改为 `framework` +- [ ] OAuth2 的 `client-id` 和 `client-secret` 已填入真实值(或关闭不用的) +- [ ] `oauth-frontend-redirect-uri` 已改为前端实际地址 +- [ ] 默认皮肤文件已放置到位 +- [ ] SSL 证书密码不是默认的 `123456`(如果由应用处理 SSL) \ No newline at end of file From b7a922f3085b51accc4133a9e9618c150262d0c7 Mon Sep 17 00:00:00 2001 From: pingguo <141195321+pingguomc@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:18:28 +0800 Subject: [PATCH 10/12] Potential fix for code scanning alert no. 3: Disabled Spring CSRF protection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/main/java/org/miowing/mioverify/config/SecurityConfig.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java index 1cbb8d7..d2c15d4 100644 --- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -147,8 +147,6 @@ public ClientRegistrationRepository clientRegistrationRepository() { @Bean public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception { http - .csrf(AbstractHttpConfigurer :: disable) - .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) From 02f6fcddbf0c6058b114724ae578c3f78a02c067 Mon Sep 17 00:00:00 2001 From: pingguomc <141195321+pingguomc@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:28:44 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20CSRF=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mioverify/config/SecurityConfig.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java index d2c15d4..6df2e70 100644 --- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java +++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java @@ -13,7 +13,6 @@ import org.springframework.http.HttpStatus; 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.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -24,6 +23,8 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.util.UriComponentsBuilder; @@ -132,7 +133,7 @@ public ClientRegistrationRepository clientRegistrationRepository() { * *

    过滤链处理顺序:

    *
      - *
    1. 禁用 CSRF
    2. + *
    3. 设置 CSRF
    4. *
    5. 设置无状态 Session(不创建 HttpSession)
    6. *
    7. 放行所有请求
    8. *
    9. 若 OAuth 总开关开启,注册 OAuth2 登录流程
    10. @@ -147,6 +148,20 @@ public ClientRegistrationRepository clientRegistrationRepository() { @Bean public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception { http + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) + .ignoringRequestMatchers( + "/authserver/**", + "/sessionserver/**", + "/minecraftservices/**", + "/oauth/callback/*", + "/api/**", + "/extern/**", + "/texture/**" + ) + ) + .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) @@ -343,7 +358,7 @@ private String extractBindNonce(String state) { /** 工具方法:重定向并带一个查询参数 */ private void redirect( jakarta.servlet.http.HttpServletResponse response, - String baseUrl, String key, String value) throws java.io.IOException { + String baseUrl, String key, String value) { String url = UriComponentsBuilder.fromHttpUrl(baseUrl) .queryParam(key, value) From cbb34232ebcb75972d3e771902f3ba782a376d48 Mon Sep 17 00:00:00 2001 From: pingguo <141195321+pingguomc@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:30:37 +0800 Subject: [PATCH 12/12] Update version to 1.4.0 in pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ec361f3..94b1498 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.miowing MioVerify - commit-0bcfa8221c049a599ba29e1eba10ed17108b39ad + 1.4.0 MioVerify A Minecraft verification server implementing Yggdrasil API