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 全局安全配置类

+ * + *

本类承担以下职责:

+ * + * + *

与原有账密登录的关系

+ *

与原有账密登录({@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 { + + /** 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} 而抛出异常。

+ * + *
    + *
  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 过滤链。 + * + *

过滤链处理顺序:

+ *
    + *
  1. 设置 CSRF
  2. + *
  3. 设置无状态 Session(不创建 HttpSession)
  4. + *
  5. 放行所有请求
  6. + *
  7. 若 OAuth 总开关开启,注册 OAuth2 登录流程
  8. + *
+ * + * @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 返回的用户名字段不同,按以下优先级依次尝试:

+ *
    + *
  1. {@code login}:GitHub 专用字段
  2. + *
  3. {@code displayName}:MCJPG 专用字段
  4. + *
  5. {@code name}:Microsoft / 其他 OIDC Provider 的通用字段
  6. + *
+ * + * @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