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/OAuthAPI.md b/OAuthAPI.md
new file mode 100644
index 0000000..a1a9e33
--- /dev/null
+++ b/OAuthAPI.md
@@ -0,0 +1,142 @@
+# MioVerify API 接口文档
+
+**Yggdrasil-服务端技术规范** 未规定,但在本项目使用的 API 的文档。
+
+## 基本约定
+
+基本上与 **Yggdrasil-服务端技术规范** 保持一致。
+
+## 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}`
+
+TODO
+
+将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页
+
+### 解绑第三方账号
+
+`DELETE /oauth/bind/{provider}`
+
+请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
+`401 Unauthorized`。
+
+若用户仅剩一个第三方认证,则解绑失败,返回 `403 Forbidden`
+
+若操作成功或从未绑定这个服务商,服务端应返回 HTTP 状态 `204 No Content`
+
+### 获取所有支持的 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`。
+
+## 密码管理
+
+## 其他(待定)
\ No newline at end of file
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/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/pom.xml b/pom.xml
index 8fbd246..94b1498 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.miowing
MioVerify
- 1.3.0-alpha
+ 1.4.0
MioVerify
A Minecraft verification server implementing Yggdrasil API
@@ -73,6 +73,10 @@
spring-boot-starter-test
test
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-client
+
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/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..6df2e70
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -0,0 +1,370 @@
+package org.miowing.mioverify.config;
+
+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.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;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+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.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.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;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Spring Security 全局安全配置类
+ *
+ * 本类承担以下职责:
+ *
+ * - 禁用 CSRF(项目为无状态 REST API,使用自定义 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/**})完全独立,互不影响。
+ *
+ * 启动顺序说明
+ *
+ * - {@link #clientRegistrationRepository} Bean 在容器启动时被创建,
+ * 仅包含 {@code enabled=true} 的 Provider
+ * - Spring Boot 的 {@code OAuth2ClientRegistrationRepositoryConfiguration}
+ * 自动配置因检测到已存在同类型 Bean 而跳过,不会再尝试解析任何 Provider 的 (主要是微软)
+ * {@code issuer-uri}
+ * - {@link #filterChain} 注入 Repository
+ *
+ */
+@Configuration
+@EnableWebSecurity
+@Slf4j
+public class SecurityConfig {
+
+ /** OAuth2 客户端配置,从 {@code application.yml} 自动绑定。包含所有 Provider 的原始配置(无论是否启用)。 */
+ @Autowired
+ private OAuth2ClientProperties oAuth2ClientProperties;
+ @Autowired
+ private OAuthService oAuthService;
+ @Autowired
+ private RedisService redisService;
+ @Autowired
+ private DataUtil dataUtil;
+
+ /**
+ * 创建仅包含已启用 Provider 的 {@link ClientRegistrationRepository} Bean。
+ *
+ * 避免启动时因请求无效的 {@code issuer-uri} 而抛出异常。
+ *
+ *
+ * - 读取 {@link DataUtil} 中各 Provider 的 {@code enabled} 开关
+ * - 从 {@link OAuth2ClientProperties} 中移除未启用 Provider 的配置
+ * - 用过滤后的配置构建 {@link InMemoryClientRegistrationRepository}
+ *
+ *
+ * @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 过滤链。
+ *
+ * 过滤链处理顺序:
+ *
+ * - 设置 CSRF
+ * - 设置无状态 Session(不创建 HttpSession)
+ * - 放行所有请求
+ * - 若 OAuth 总开关开启,注册 OAuth2 登录流程
+ *
+ *
+ * @param http Spring Security 的 HttpSecurity 构造器
+ * @param clientRegistrationRepository 已过滤的 Provider 注册仓库,由 {@link #clientRegistrationRepository()} 提供
+ * @return 构建完成的 SecurityFilterChain
+ *
+ * @throws Exception 配置异常
+ */
+ @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)
+ )
+
+ .authorizeHttpRequests(auth -> auth
+ .anyRequest().permitAll() //放行全部接口
+ );
+
+ if ( dataUtil.isOAuthEnabled() ) {
+
+ http.oauth2Login(oauth2 -> oauth2
+ // 接管 /oauth/authorize/{provider}
+ .authorizationEndpoint(auth -> auth
+ .baseUri("/oauth/authorize")
+ .authorizationRequestResolver(buildAuthorizationRequestResolver(clientRegistrationRepository))
+ )
+ // 接管 /oauth/callback/{provider}
+ .redirectionEndpoint(redirect -> redirect
+ .baseUri("/oauth/callback/*")
+ )
+ // 只注册配置文件中 enabled=true 的 Provider
+ .clientRegistrationRepository(clientRegistrationRepository)
+ .successHandler(oAuth2SuccessHandler())
+ // 登录失败
+ .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);
+ })
+ );
+ }
+
+ return http.build();
+ }
+
+ /**
+ * OAuth2 登录成功处理器。
+ *
+ * 当 Spring Security 完成以下工作后,本处理器被调用:
+ *
+ * - 用 code 向 Provider 换取 Access Token
+ * - 用 Access Token 调用 UserInfo 端点获取用户信息
+ *
+ *
+ * 本处理器负责:
+ *
+ * - 从 {@link OAuth2User} 中提取 {@code providerUserId} 和 {@code providerUsername}
+ * - 从 {@code state} 参数中识别是否为账号绑定流程(携带 {@code bindnonce})
+ * - 绑定流程:校验 nonce 有效性,调用 {@link OAuthService#handleOAuthBind} 完成绑定
+ * - 登录流程:调用 {@link OAuthService#handleOAuthLogin} 完成用户查找或创建,
+ * 签发临时 Token 并重定向前端
+ *
+ *
+ * @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);
+ String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri();
+
+ // 从 state 参数里尝试提取 bindNonce
+ String state = request.getParameter("state");
+ String bindNonce = extractBindNonce(state);
+
+ 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);
+
+ // 交给 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();
+
+ // 发送重定向
+ response.setStatus(HttpStatus.FOUND.value());
+ response.setHeader("Location", redirectUrl);
+ }
+ };
+ }
+
+
+ /**
+ * 构建自定义授权请求解析器。
+ *
+ * 当请求 {@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(ClientRegistrationRepository clientRegistrationRepository) {
+ 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 解析用户名。
+ *
+ * 各 Provider 返回的用户名字段不同,按以下优先级依次尝试:
+ *
+ * - {@code login}:GitHub 专用字段
+ * - {@code displayName}:MCJPG 专用字段
+ * - {@code name}:Microsoft / 其他 OIDC Provider 的通用字段
+ *
+ *
+ * @param oAuth2User Spring Security 封装的 OAuth2 用户对象
+ * @return 解析到的用户名,若所有字段均为空则返回 {@code 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 / 其他
+ }
+
+ /** 从 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(",") ) {
+ 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) {
+
+ 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/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 534482e..4e10342 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,13 @@ public ResponseEntity> handleNotFound() {
)
public ResponseEntity> handleForbidden() {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
+ //
}
+
+ @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 89603d4..9e0ba49 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()) {
@@ -56,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
new file mode 100644
index 0000000..f641045
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
@@ -0,0 +1,279 @@
+package org.miowing.mioverify.controller;
+
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+import org.miowing.mioverify.dao.UserDao;
+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;
+import org.miowing.mioverify.pojo.User;
+import org.miowing.mioverify.pojo.oauth.OAuthAuthReq;
+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;
+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.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;
+import java.util.Map;
+
+/**
+ * OAuth 2.0 控制器
+ * 所有端点均以 /oauth 开头,处理第三方登录、绑定、解绑等操作
+ */
+@Slf4j
+@RestController
+@RequestMapping("/oauth")
+public class OAuthController {
+
+ @Autowired
+ private UserDao userDao;
+
+ @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;
+
+ /**
+ * 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.isRequestUser() ? util.userToShow(user) : null);
+ }
+
+ /**
+ * 获取所有支持的 OAuth 提供商及当前用户的绑定状态
+ */
+ @GetMapping("/providers")
+ public OAuthProviderListResp getProviders(@RequestHeader("Authorization") String authorization) {
+ if ( ! dataUtil.isOAuthEnabled() ) {
+ throw new FeatureNotSupportedException();
+ }
+
+ User user = getUserFromAuthorization(authorization);
+
+ 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,
+ @RequestHeader("Authorization") String authorization) {
+ if ( ! dataUtil.isOAuthEnabled() ) {
+ throw new FeatureNotSupportedException();
+ }
+
+ 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());
+
+ 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);
+
+ return ResponseEntity.ok(Map.of("authorizationUrl", authUrl));
+ }
+
+ /**
+ * 解绑第三方账号(需要登录)
+ */
+ @DeleteMapping("/bind/{provider}")
+ public ResponseEntity> unbindProvider(
+ @PathVariable String provider,
+ @RequestHeader("Authorization") String authorization) {
+ if ( ! dataUtil.isOAuthEnabled() ) {
+ 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
+ }
+
+ String accessToken = authorization.substring(7);
+ AToken aToken = tokenUtil.verifyAccessToken(accessToken, null, true);
+
+ if ( aToken == null ) {
+ throw new UnauthorizedException(); // 401
+ }
+
+ // 通过绑定的 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
+ }
+
+ return user;
+ }
+
+}
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/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/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/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/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/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 2d49590..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")
@@ -13,6 +16,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/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..9c46b32
--- /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/OAuthCallbackResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java
new file mode 100644
index 0000000..1669fab
--- /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 回调响应 (临时作为数据中转类)
+ * 用于返回临时令牌和用户信息
+ */
+@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..d7e1936
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java
@@ -0,0 +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/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/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/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/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..4c19526
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/service/OAuthService.java
@@ -0,0 +1,37 @@
+package org.miowing.mioverify.service;
+
+import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp;
+
+public interface OAuthService {
+
+ /**
+ * 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 等信息
+ */
+ OAuthCallbackResp handleOAuthLogin(String provider, String providerUserId, String providerUsername);
+
+ /**
+ * 解绑用户与指定 OAuth 提供商的关联
+ *
+ * @param userId 用户 ID
+ * @param provider 提供商名称(如 "microsoft")
+ */
+ 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 b64cfb4..26a0bd7 100644
--- a/src/main/java/org/miowing/mioverify/service/RedisService.java
+++ b/src/main/java/org/miowing/mioverify/service/RedisService.java
@@ -1,9 +1,48 @@
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);
+
+ /**
+ * 保存 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/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..b163472
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
@@ -0,0 +1,215 @@
+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.DuplicateUserNameException;
+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)
+ );
+
+ 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());
+
+ return new OAuthCallbackResp()
+ .setTempToken(tempToken)
+ .setProvider(provider)
+ .setProviderUsername(providerUsername)
+ .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 提供商
+ }
+
+ 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);
+
+ 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);
+ }
+
+ //-----------
+
+ /** 创建新用户 */
+ private User createUser(String provider, String providerUserId, String providerUsername,
+ BiConsumer setter) {
+
+ User newUser = new User();
+ newUser.setId(Util.genUUID()); // 使用工具类生成 ID
+
+ // 用户名处理:优先使用提供商返回的用户名,否则生成默认名
+ 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 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/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
index 642934a..32e5bbe 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,36 @@
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_TEMP_TOKEN_PREF = "oauth:temp:";
+ private static final String OAUTH_BIND_NONCE_PREF = "oauth:bind:";
+
@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 +51,7 @@ public void removeToken(String token) {
hops.delete(userIdP, token);
}
}
+
@Override
public void clearToken(String userId) {
String userIdP = TokenUtil.USERID_PREF + userId;
@@ -49,8 +62,34 @@ 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);
+ }
+
+ @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/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/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/java/org/miowing/mioverify/util/DataUtil.java b/src/main/java/org/miowing/mioverify/util/DataUtil.java
index d3710d2..0c32cd6 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;
@@ -55,8 +56,33 @@ 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 设置
+ @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;
+ @Value("${mioverify.oauth-frontend-redirect-uri}")
+ private String oauthFrontendRedirectUri;
+
@Override
public void afterPropertiesSet() throws Exception {
log.info("Reading data from application file...");
+ //
}
+
}
\ No newline at end of file
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/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java
index 4e31cc4..8628aea 100644
--- a/src/main/java/org/miowing/mioverify/util/Util.java
+++ b/src/main/java/org/miowing/mioverify/util/Util.java
@@ -9,6 +9,8 @@
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.*;
import java.util.Base64;
@@ -51,7 +53,7 @@ public String getTextureURL(String hash) {
return getServerURL() + "/texture/hash/" + hash;
}
public String getServerURL() {
- return dataUtil.isUseHttps() ? "https://" : "http://" + dataUtil.getServerDomain() + ":" + dataUtil.getPort();
+ 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 190a855..7adc960 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -17,7 +17,62 @@ server:
key-alias: boot
servlet:
application-display-name: MioVerify Alpha
+ # 自动适配 Nginx 和 Cloudflare,如果你使用了这两个东西,请设为 native 或 framework(更建议) 。而不是 none
+ forward-headers-strategy: none
+
+
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/common/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:
# 允许文件上传
@@ -27,7 +82,7 @@ spring:
# 最大上传请求尺寸
max-request-size: 2MB
profiles:
- # 选择数据库,"test-sqlite" 或 "test-mysql"
+ # 选择数据库,"test-sqlite" 或 "test-mysql",这两个在下面配置
active: test-sqlite
data:
# 设置Redis配置
@@ -36,10 +91,24 @@ spring:
# url: ...
host: localhost
port: 6379
+
+
mybatis-plus:
global-config:
banner: false
+
+
mioverify:
+ # OAuth 前端回调地址 (路径不可修改)
+ oauth-frontend-redirect-uri: "https://your-frontend.com/oauth/callback"
+
+ # 跨域配置,生产环境中必须设置
+ cors:
+ # 是否启用跨域配置(若在 CDN 中配置或不跨域,可以不启用)
+ enabled: false
+ # 允许跨域的域名(* 表示允许所有,但生产环境不建议。)
+ allowed-origin: "https://your-domain.com"
+
# 扩展API配置项
extern:
register:
@@ -60,6 +129,7 @@ mioverify:
key: admin123098
# 是否允许重复角色名(仅发生在注册和修改)
multi-profile-name: true
+
token:
# 给角色(Profile)签名的文本
signature: 'abcd273nsi179a'
@@ -67,9 +137,11 @@ mioverify:
expire: 10m
# token永久过期时间
invalid: 1h
+
session:
# session过期时间
expire: 6m
+
security:
# 公钥路径
public-key-loc: keys/public.pem
@@ -79,12 +151,14 @@ mioverify:
sign-algorithm: SHA1withRSA
# 批量获取角色(Profile)API最大限制数量
profile-batch-limit: 5
+
texture:
# 材质存储位置
storage-loc: textures
# 默认皮肤存储位置
default-skin-loc: textures/skin/default.png
- # 服务器元数据,详见Yggdrasil API
+
+ # 服务器元数据,详见Yggdrasil API
props:
meta:
# 将会显示在启动器
@@ -92,7 +166,7 @@ mioverify:
# 实现的名称
implementation-name: mioverify
# 实现的版本
- implementation-version: '0.0.1'
+ 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
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
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
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..e69de29
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