().eq(User :: getUsername, aToken.name()));
+
+ if ( user == null ) {
+ throw new UnauthorizedException(); // 401
+ }
+ oAuthService.unbind(user.getId(), provider); // 取消成功和未绑定均返回 204
+
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * OAuth2 登录失败后的跳转端点
+ * 由 Spring Security 在 OAuth2 流程失败时自动重定向至此
+ */
+ @GetMapping("/error")
+ public void oauthError() {
+ throw new LoginFailedException();
+ }
}
diff --git a/src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java b/src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java
new file mode 100644
index 0000000..8f1d4be
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/exception/ServerConfigurationException.java
@@ -0,0 +1,5 @@
+package org.miowing.mioverify.exception;
+
+public class ServerConfigurationException extends MioVerifyException {
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/Profile.java b/src/main/java/org/miowing/mioverify/pojo/Profile.java
index 9e0928e..6c4e41c 100644
--- a/src/main/java/org/miowing/mioverify/pojo/Profile.java
+++ b/src/main/java/org/miowing/mioverify/pojo/Profile.java
@@ -6,6 +6,9 @@
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
+/**
+ * 角色 数据表映射
+ */
@Data
@Accessors(chain = true)
@TableName("profiles")
diff --git a/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java b/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java
index be45f69..c2f3690 100644
--- a/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java
+++ b/src/main/java/org/miowing/mioverify/pojo/ProfileShow.java
@@ -7,6 +7,24 @@
import java.util.List;
+/**
+ * 角色信息
+ * 序列化后的 Json 格式,不对应数据库
+ *
+ * {
+ * "id":"角色 UUID(无符号)",
+ * "name":"角色名称",
+ * "properties":[ // 角色的属性(数组,每一元素为一个属性)(仅在特定情况下需要包含)
+ * { // 一项属性
+ * "name":"属性的名称",
+ * "value":"属性的值",
+ * "signature":"属性值的数字签名(仅在特定情况下需要包含)"
+ * }
+ * // ,...(可以有更多)
+ * ]
+ * }
+ *
+ */
@Data
@Accessors(chain = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
diff --git a/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java b/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java
index 6dc9bfe..59426e0 100644
--- a/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java
+++ b/src/main/java/org/miowing/mioverify/pojo/ServerMeta.java
@@ -10,6 +10,10 @@
import java.util.List;
+/**
+ * 服务器元数据
+ * 返回 Json 格式
+ */
@Data
@Accessors(chain = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
diff --git a/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java b/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java
index 6ab4a56..6af2eaf 100644
--- a/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java
+++ b/src/main/java/org/miowing/mioverify/pojo/TexturesShow.java
@@ -5,6 +5,27 @@
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
+/**
+ * 材质信息
+ * 序列化后的 Json 格式,不对应数据库
+ *
+ * {
+ * "timestamp":该属性值被生成时的时间戳(Java 时间戳格式,即自 1970-01-01 00:00:00 UTC 至今经过的毫秒数),
+ * "profileId":"角色 UUID(无符号)",
+ * "profileName":"角色名称",
+ * "textures":{ // 角色的材质
+ * "材质类型(如 SKIN)":{ // 若角色不具有该项材质,则不必包含
+ * "url":"材质的 URL",
+ * "metadata":{ // 材质的元数据,若没有则不必包含
+ * "名称":"值"
+ * // ,...(可以有更多)
+ * }
+ * }
+ * // ,...(可以有更多)
+ * }
+ * }
+ *
+ */
@Data
@Accessors(chain = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
diff --git a/src/main/java/org/miowing/mioverify/pojo/User.java b/src/main/java/org/miowing/mioverify/pojo/User.java
index 2c80c76..49a58b1 100644
--- a/src/main/java/org/miowing/mioverify/pojo/User.java
+++ b/src/main/java/org/miowing/mioverify/pojo/User.java
@@ -6,6 +6,9 @@
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
+/**
+ * 用户 数据表映射
+ */
@Data
@Accessors(chain = true)
@TableName("users")
diff --git a/src/main/java/org/miowing/mioverify/pojo/UserShow.java b/src/main/java/org/miowing/mioverify/pojo/UserShow.java
index 46ffa4f..ecd9f75 100644
--- a/src/main/java/org/miowing/mioverify/pojo/UserShow.java
+++ b/src/main/java/org/miowing/mioverify/pojo/UserShow.java
@@ -3,8 +3,25 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.experimental.Accessors;
+
import java.util.List;
+/**
+ * 用户信息
+ * 序列化后的 Json 格式,不对应数据库
+ *
+ * {
+ * "id":"用户的 ID",
+ * "properties":[ // 用户的属性(数组,每一元素为一个属性)
+ * { // 一项属性
+ * "name":"属性的名称",
+ * "value":"属性的值",
+ * }
+ * // ,...(可以有更多)
+ * ]
+ * }
+ *
+ */
@Data
@Accessors(chain = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java
new file mode 100644
index 0000000..9ba9467
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java
@@ -0,0 +1,22 @@
+package org.miowing.mioverify.pojo.oauth;
+
+import lombok.Data;
+import org.springframework.lang.Nullable;
+
+/**
+ * 用临时 Token 换取正式 accessToken 的请求体
+ * 用于 POST /oauth/authenticate
+ */
+@Data
+public class OAuthAuthReq {
+
+ /** OAuth 登录回调后返回的临时 Token */
+ private String tempToken;
+
+ /** 客户端 Token,为空时由服务端生成 */
+ private @Nullable String clientToken;
+
+ /** 是否在响应中返回用户信息 */
+ private Boolean requestUser;
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java
new file mode 100644
index 0000000..daeab4f
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java
@@ -0,0 +1,19 @@
+package org.miowing.mioverify.pojo.oauth;
+
+import lombok.Data;
+
+
+/**
+ * 手动绑定第三方账号的请求体
+ * 用于 POST /oauth/{provider}/bind
+ */
+@Data
+public class OAuthBindReq {
+
+ /** Provider 返回的用户唯一 ID */
+ private String providerUserId;
+
+ /** Provider 返回的用户名 */
+ private String providerUsername;
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java
new file mode 100644
index 0000000..66430a9
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java
@@ -0,0 +1,20 @@
+package org.miowing.mioverify.pojo.oauth;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * OAuth 回调响应
+ * 用于 /oauth/callback/{provider} 接口返回临时令牌和用户信息
+ */
+@Data
+@Accessors(chain = true)
+public class OAuthCallbackResp {
+
+ private String tempToken; // 临时令牌,用于后续换取 accessToken
+ private String provider; // 提供商名称 (github/microsoft/mcjpg/custom)
+ private String providerUsername; // 第三方平台上的用户名
+ private boolean needProfile; // 是否需要创建角色(profile)
+ private String userId; // 当 needProfile=true 时返回用户ID,便于前端后续创建角色
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java
new file mode 100644
index 0000000..6078559
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java
@@ -0,0 +1,5 @@
+package org.miowing.mioverify.pojo.oauth;
+
+public class OAuthProviderListResp {
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java
new file mode 100644
index 0000000..b499d72
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java
@@ -0,0 +1,14 @@
+package org.miowing.mioverify.pojo.oauth;
+
+import lombok.Data;
+import org.springframework.lang.Nullable;
+
+@Data
+public class OAuthState {
+
+ private String provider;
+ private boolean bind; // 是否为绑定模式
+ private @Nullable String userId; // 绑定模式下的当前用户ID
+ private String redirectUri; // 登录成功后重定向地址
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java
new file mode 100644
index 0000000..ec71ba4
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthStatusResp.java
@@ -0,0 +1,23 @@
+package org.miowing.mioverify.pojo.oauth;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.util.List;
+
+/**
+ * OAuth 状态获取响应
+ */
+@Data
+@Accessors(chain = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class OAuthStatusResp {
+
+ /** OAuth 是否已经启用 */
+ private boolean enabled;
+
+ /** Provider 列表 */
+ private List providers;
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java
new file mode 100644
index 0000000..0faad5b
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthUserInfo.java
@@ -0,0 +1,14 @@
+package org.miowing.mioverify.pojo.oauth;
+
+import lombok.Data;
+import org.springframework.lang.Nullable;
+
+@Data
+public class OAuthUserInfo {
+
+ private String provider; // 提供商名称
+ private String providerUserId; // 提供商返回的唯一用户ID
+ private @Nullable String providerUsername; // 提供商返回的用户名
+ private @Nullable String email; // 邮箱
+
+}
diff --git a/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java
similarity index 91%
rename from src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java
rename to src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java
index 9d24416..99a4d13 100644
--- a/src/main/java/org/miowing/mioverify/pojo/request/OauthUserRegisterReq.java
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java
@@ -1,4 +1,4 @@
-package org.miowing.mioverify.pojo.request;
+package org.miowing.mioverify.pojo.oauth;
import lombok.Data;
import org.springframework.lang.Nullable;
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java b/src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java
new file mode 100644
index 0000000..940cd5f
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/package-info.java
@@ -0,0 +1 @@
+package org.miowing.mioverify.pojo.oauth;
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/service/OAuthService.java b/src/main/java/org/miowing/mioverify/service/OAuthService.java
index d166d7e..22cb616 100644
--- a/src/main/java/org/miowing/mioverify/service/OAuthService.java
+++ b/src/main/java/org/miowing/mioverify/service/OAuthService.java
@@ -1,27 +1,28 @@
package org.miowing.mioverify.service;
-import jakarta.servlet.http.HttpServletRequest;
-
-import java.util.List;
+import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp;
public interface OAuthService {
+
/**
- * 获取启用的OAuth提供商列表(内部标识)
+ * OAuth2 登录成功后的用户处理(由 SecurityConfig.successHandler 调用)。
+ * Spring Security 已自动完成 code 换 token 和获取用户信息,
+ * 此方法负责 查找/创建本地用户 并签发临时 Token。
+ *
+ * @param provider Provider 名称(github / microsoft / mcjpg / custom)
+ * @param providerUserId Provider 返回的用户唯一 ID
+ * @param providerUsername Provider 返回的用户名
+ *
+ * @return OAuthCallbackResp 包含临时 Token 等信息
*/
- List getEnabledProviders();
-
-// /** TODO
-// * 获取所有支持的OAuth提供商详情
-// */
-// List getSupportedProviders();
+ OAuthCallbackResp handleOAuthLogin(String provider, String providerUserId, String providerUsername);
/**
- * 构建授权跳转URL
+ * 解绑用户与指定 OAuth 提供商的关联
+ *
+ * @param userId 用户 ID
+ * @param provider 提供商名称(如 "microsoft")
*/
- String buildAuthorizationUrl(String provider, boolean bind, HttpServletRequest request);
+ void unbind(String userId, String provider);
-// /** TODO
-// * 用授权码换取用户信息
-// */
-// OAuthUserInfo exchangeCodeAndGetUserInfo(String provider, String code);
}
diff --git a/src/main/java/org/miowing/mioverify/service/RedisService.java b/src/main/java/org/miowing/mioverify/service/RedisService.java
index b64cfb4..daeac14 100644
--- a/src/main/java/org/miowing/mioverify/service/RedisService.java
+++ b/src/main/java/org/miowing/mioverify/service/RedisService.java
@@ -1,9 +1,29 @@
package org.miowing.mioverify.service;
public interface RedisService {
+
void saveToken(String token, String userId);
boolean checkToken(String token);
void removeToken(String token);
void clearToken(String userId);
void saveSession(String serverId, String token);
+
+ /**
+ * 保存 OAuth 临时令牌,用于后续换取正式的 accessToken。
+ * 临时令牌需设置较短的过期时间,防止滥用。
+ *
+ * @param tempToken 生成的临时令牌(通常为 UUID)
+ * @param userId 对应的用户 ID
+ */
+ void saveOAuthTempToken(String tempToken, String userId);
+
+ /**
+ * 消费 OAuth 临时令牌。
+ * 根据临时令牌获取用户 ID,并立即删除该记录,确保一次性使用。
+ *
+ * @param tempToken 待消费的临时令牌
+ * @return 若令牌有效且未过期,返回对应的用户 ID;否则返回 null
+ */
+ String consumeOAuthTempToken(String tempToken);
+
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/service/UserService.java b/src/main/java/org/miowing/mioverify/service/UserService.java
index 55f9fea..7a9b278 100644
--- a/src/main/java/org/miowing/mioverify/service/UserService.java
+++ b/src/main/java/org/miowing/mioverify/service/UserService.java
@@ -7,4 +7,13 @@ public interface UserService extends IService {
User getLogin(String username, String password);
User getLogin(String username, String password, boolean exception);
User getLoginNoPwd(String username);
+
+ /**
+ * 按 provider 的用户ID 查询用户(对应各字段的 UNIQUE 查询)
+ *
+ * @param providerUserId 用户 ID
+ *
+ * @return {@link User} 实例
+ */
+ User getByProviderUserId(String provider, String providerUserId);
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
new file mode 100644
index 0000000..b7be4dc
--- /dev/null
+++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
@@ -0,0 +1,136 @@
+package org.miowing.mioverify.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+import org.miowing.mioverify.dao.UserDao;
+import org.miowing.mioverify.exception.FeatureNotSupportedException;
+import org.miowing.mioverify.pojo.User;
+import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp;
+import org.miowing.mioverify.service.OAuthService;
+import org.miowing.mioverify.service.ProfileService;
+import org.miowing.mioverify.service.RedisService;
+import org.miowing.mioverify.service.UserService;
+import org.miowing.mioverify.util.DataUtil;
+import org.miowing.mioverify.util.TokenUtil;
+import org.miowing.mioverify.util.Util;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.function.BiConsumer;
+
+@Service
+@Slf4j
+public class OAuthServiceImpl implements OAuthService {
+
+ @Autowired
+ private TokenUtil tokenUtil;
+ @Autowired
+ private DataUtil dataUtil;
+ @Autowired
+ private Util util;
+
+ @Autowired
+ private RedisService redisService;
+ @Autowired
+ private UserService userService;
+ @Autowired
+ private ProfileService profileService;
+
+ @Autowired
+ private UserDao userDao;
+
+
+ /** Provider 与数据库字段设置器的映射(用于设置对应的 provider ID) */
+ private static final java.util.Map> PROVIDER_SETTER = java.util.Map.of(
+ "github", User :: setGithubId,
+ "microsoft", User :: setMicrosoftId,
+ "mcjpg", User :: setMcjpgId,
+ "custom", User :: setCustomId
+ );
+
+ /** Provider 与数据库字段名的映射(用于查询) */
+ private static final java.util.Map PROVIDER_FIELD = java.util.Map.of(
+ "github", "github_id",
+ "microsoft", "microsoft_id",
+ "mcjpg", "mcjpg_id",
+ "custom", "custom_id"
+ );
+
+
+ @Override
+ public OAuthCallbackResp handleOAuthLogin(
+ @NonNull String provider, @NonNull String providerUserId, String providerUsername) {
+
+ // 参数校验
+ BiConsumer setter = PROVIDER_SETTER.get(provider);
+ String fieldName = PROVIDER_FIELD.get(provider);
+ if ( setter == null || fieldName == null ) {
+ throw new FeatureNotSupportedException(); //不支持的 OAuth 提供商
+ }
+ if ( providerUserId.isBlank() ) {
+ throw new IllegalArgumentException(); // 提供商用户 ID 不能为空
+ }
+
+ User user = userDao.selectOne(
+ new QueryWrapper().eq(fieldName, providerUserId)
+ );
+
+ if ( user == null ) {
+ user = createUser(provider, providerUserId, providerUsername, setter);
+ }
+
+ // 生成临时令牌并存入 Redis
+ String tempToken = Util.genUUID();
+ redisService.saveOAuthTempToken(tempToken, user.getId());
+
+ return new OAuthCallbackResp()
+ .setTempToken(tempToken)
+ .setProvider(provider)
+ .setProviderUsername(providerUsername)
+ .setNeedProfile(false); //暂时设置为 false
+ }
+
+
+ @Override
+ public void unbind(@NonNull String userId, @NonNull String provider) {
+ BiConsumer setter = PROVIDER_SETTER.get(provider);
+ if ( setter == null ) {
+ throw new FeatureNotSupportedException();//不支持的 OAuth 提供商
+ }
+
+ User user = userDao.selectById(userId);
+
+ // 清空对应的 provider ID
+ setter.accept(user, null);
+ userDao.updateById(user);
+
+ log.info("User: {} ,Unbind oauth provider: {}", userId, provider);
+ }
+
+
+ //-----------
+
+
+ /**
+ * 创建新用户
+ */
+ private User createUser(String provider, String providerUserId, String providerUsername,
+ BiConsumer setter) {
+
+ User newUser = new User();
+ newUser.setId(Util.genUUID()); // 使用工具类生成 ID
+ // 用户名处理:优先使用提供商返回的用户名,否则生成默认名
+ String username = (providerUsername != null && ! providerUsername.isBlank())
+ ? providerUsername
+ : "user_" + providerUserId.substring(0, Math.min(8, providerUserId.length()));
+ newUser.setUsername(username);
+ // 设置对应的 provider ID
+ setter.accept(newUser, providerUserId);
+
+ log.info("New user register: {}", newUser.getUsername());
+ userService.save(newUser);
+ return newUser;
+ }
+
+}
diff --git a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
index 642934a..ac25ffe 100644
--- a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
+++ b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
@@ -8,24 +8,37 @@
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
+
import java.util.Set;
+import java.util.concurrent.TimeUnit;
+/**
+ * Redis 操作
+ */
@Service
public class RedisServiceImpl implements RedisService {
+
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private DataUtil dataUtil;
+
+
+ private static final String OAUTH_STATE_PREF = "oauth:state:";
+ private static final String OAUTH_TEMP_TOKEN_PREF = "oauth:temp:";
+
@Override
public void saveToken(String token, String userId) {
redisTemplate.opsForValue().set(TokenUtil.TOKEN_PREF + token, userId);
redisTemplate.opsForValue().set(TokenUtil.TMARK_PREF + token, "", dataUtil.getTokenInvalid());
redisTemplate.opsForHash().put(TokenUtil.USERID_PREF + userId, token, "");
}
+
@Override
public boolean checkToken(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey(TokenUtil.TOKEN_PREF + token));
}
+
@Override
public void removeToken(String token) {
String userId = redisTemplate.opsForValue().get(TokenUtil.TOKEN_PREF + token);
@@ -39,6 +52,7 @@ public void removeToken(String token) {
hops.delete(userIdP, token);
}
}
+
@Override
public void clearToken(String userId) {
String userIdP = TokenUtil.USERID_PREF + userId;
@@ -49,8 +63,22 @@ public void clearToken(String userId) {
}
redisTemplate.delete(userIdP);
}
+
@Override
public void saveSession(String serverId, String token) {
redisTemplate.opsForValue().set(SessionUtil.SESSION_PREF + serverId, token, dataUtil.getSessionExpire());
}
+
+ @Override
+ public void saveOAuthTempToken(String tempToken, String userId) {
+ String key = OAUTH_TEMP_TOKEN_PREF + tempToken;
+ redisTemplate.opsForValue().set(key, userId, dataUtil.getOauthExpire().getSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Override
+ public String consumeOAuthTempToken(String tempToken) {
+ String key = OAUTH_TEMP_TOKEN_PREF + tempToken;
+ return redisTemplate.opsForValue().getAndDelete(key);
+ }
+
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java
index 75e135f..71ade7e 100644
--- a/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java
+++ b/src/main/java/org/miowing/mioverify/service/impl/UserServiceImpl.java
@@ -14,7 +14,9 @@ public class UserServiceImpl extends ServiceImpl implements UserS
@Override
public User getLogin(String username, String password) {
return getLogin(username, password, false);
+ //
}
+
@Override
public User getLogin(String username, String password, boolean exception) {
LambdaQueryWrapper lqw = new LambdaQueryWrapper<>();
@@ -25,10 +27,27 @@ public User getLogin(String username, String password, boolean exception) {
}
return user;
}
+
@Override
public @Nullable User getLoginNoPwd(String username) {
LambdaQueryWrapper lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getUsername, username);
return getOne(lqw);
}
+
+ @Override
+ public User getByProviderUserId(String provider, String providerUserId) {
+ LambdaQueryWrapper lqw = new LambdaQueryWrapper<>();
+ switch ( provider.toLowerCase() ) {
+ case "github" -> lqw.eq(User :: getGithubId, providerUserId);
+ case "microsoft" -> lqw.eq(User :: getMicrosoftId, providerUserId);
+ case "mcjpg" -> lqw.eq(User :: getMcjpgId, providerUserId);
+ case "custom" -> lqw.eq(User :: getCustomId, providerUserId);
+ default -> {
+ return null;
+ }
+ }
+ return getOne(lqw);
+ }
+
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/util/DataUtil.java b/src/main/java/org/miowing/mioverify/util/DataUtil.java
index 7399fbe..05fe3af 100644
--- a/src/main/java/org/miowing/mioverify/util/DataUtil.java
+++ b/src/main/java/org/miowing/mioverify/util/DataUtil.java
@@ -5,6 +5,7 @@
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
+
import java.nio.file.Path;
import java.time.Duration;
@@ -62,13 +63,19 @@ public class DataUtil implements InitializingBean {
@Value("${mioverify.cors.allowed-origin}")
private String corsAllowedOrigin;
- //以下内容为 Oauth 配置
- /**
- * 是否启用 Oauth 模式
- */
- @Value("${mioverify.oauth.enabled}")
- private boolean isOAuthMode;
-
+ //以下为 Oauth 设置
+ @Value("${spring.security.oauth2.enabled}")
+ private boolean oAuthEnabled;
+ @Value("${spring.security.oauth2.client.registration.github.enabled}")
+ private boolean oAuthGitHubEnabled;
+ @Value("${spring.security.oauth2.client.registration.microsoft.enabled}")
+ private boolean oAuthMicrosoftEnabled;
+ @Value("${spring.security.oauth2.client.registration.mcjpg.enabled}")
+ private boolean oAuthMcjpgEnabled;
+ @Value("${spring.security.oauth2.client.registration.custom.enabled}")
+ private boolean oAuthCustomEnabled;
+ @Value("${spring.security.oauth2.expire:5m}")
+ private Duration oauthExpire;
@Override
public void afterPropertiesSet() throws Exception {
diff --git a/src/main/java/org/miowing/mioverify/util/TokenUtil.java b/src/main/java/org/miowing/mioverify/util/TokenUtil.java
index 38ee2a5..790e6f8 100644
--- a/src/main/java/org/miowing/mioverify/util/TokenUtil.java
+++ b/src/main/java/org/miowing/mioverify/util/TokenUtil.java
@@ -8,12 +8,16 @@
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
+import jakarta.servlet.http.HttpServletRequest;
import org.miowing.mioverify.pojo.AToken;
+import org.miowing.mioverify.pojo.Profile;
import org.miowing.mioverify.pojo.User;
+import org.miowing.mioverify.service.ProfileService;
import org.miowing.mioverify.service.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
+
import java.util.Date;
/**
@@ -25,12 +29,31 @@ public class TokenUtil {
private DataUtil dataUtil;
@Autowired
private RedisService redisService;
+ @Autowired
+ private ProfileService profileService;
+
+ //TODO 控制优化
public static String TOKEN_PREF = "tk_";
public static String TMARK_PREF = "tm_";
public static String USERID_PREF = "tv_";
+
+ /**
+ * 生成随机的客户端令牌
+ *
+ * @return 客户端令牌字符串
+ */
public static String genClientToken() {
return IdUtil.simpleUUID();
+ //
}
+
+ /**
+ * 生成访问令牌(JWT)
+ * @param user 用户对象
+ * @param clientToken 客户端令牌(可为 null,此时自动生成)
+ * @param bindProfile 绑定的角色 ID
+ * @return JWT 访问令牌
+ */
public String genAccessToken(User user, @Nullable String clientToken, String bindProfile) {
Date invalidAt = new Date(System.currentTimeMillis() + dataUtil.getTokenInvalid().toMillis());
return JWT.create()
@@ -41,6 +64,14 @@ public String genAccessToken(User user, @Nullable String clientToken, String bin
.withExpiresAt(invalidAt)
.sign(Algorithm.HMAC256(dataUtil.getTokenSign()));
}
+
+ /**
+ * 验证访问令牌的有效性
+ * @param accessToken 访问令牌字符串
+ * @param clientToken 客户端令牌(可为 null,不校验)
+ * @param strictExpire 是否严格检查弱过期时间(wexp)
+ * @return 如果验证通过返回 {@link AToken} 对象,否则返回 null
+ */
public @Nullable AToken verifyAccessToken(String accessToken, @Nullable String clientToken, boolean strictExpire) {
try {
DecodedJWT dJWT = JWT
@@ -73,4 +104,32 @@ public String genAccessToken(User user, @Nullable String clientToken, String bin
return null;
}
}
+
+ /**
+ * 从 HTTP 请求中解析当前登录用户的 ID
+ *
+ * 从 Authorization 头中提取 Bearer 令牌,验证令牌有效性,并通过绑定的角色 ID 获取用户 ID。
+ * 令牌必须有效且未过期(严格模式)。
+ *
+ *
+ * @param request HTTP 请求对象
+ *
+ * @return 当前登录用户的 ID,如果未登录或令牌无效则返回 null
+ */
+ public @Nullable String getCurrentUserId(HttpServletRequest request) {
+ String authHeader = request.getHeader("Authorization");
+ if ( authHeader == null || ! authHeader.startsWith("Bearer ") ) {
+ return null;
+ }
+ String token = authHeader.substring(7);
+ // 验证 token,严格检查过期时间(strictExpire = true)
+ AToken aToken = verifyAccessToken(token, null, true);
+ if ( aToken == null ) {
+ return null;
+ }
+ // 通过绑定的 Profile ID 获取用户 ID
+ Profile profile = profileService.getById(aToken.bindProfile());
+ return profile != null ? profile.getBindUser() : null;
+ }
+
}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 7797c8f..a78d578 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -17,7 +17,60 @@ server:
key-alias: boot
servlet:
application-display-name: MioVerify Alpha
+
+
spring:
+
+ security:
+ oauth2:
+ enabled: true
+ # State 和 TempToken 有效期
+ expire: 5m
+ client:
+ registration:
+
+ github:
+ enabled: true
+ client-id: "your-github-client-id"
+ client-secret: "your-github-client-secret"
+ redirect-uri: "{baseUrl}/oauth/callback/github"
+ scope: read:user, user:email
+
+ microsoft:
+ enabled: true
+ client-id: "client-id"
+ client-secret: "client-secret"
+ redirect-uri: "{baseUrl}/oauth/callback/microsoft"
+ scope: openid, profile, email
+
+ mcjpg:
+ enabled: true
+ client-id: "client-id"
+ client-secret: "client-secret"
+ redirect-uri: "{baseUrl}/oauth/callback/mcjpg"
+ scope: openid, profile, email
+
+ custom:
+ enabled: true
+ client-id: "client-id"
+ client-secret: "client-secret"
+ redirect-uri: "{baseUrl}/oauth/callback/custom"
+ scope: read
+ client-name: "custom"
+
+ provider:
+ microsoft:
+ issuer-uri: "https://login.microsoftonline.com/your-tenant-id/v2.0"
+ user-name-attribute: name
+ mcjpg:
+ issuer-uri: "https://sso.mcjpg.org"
+ user-name-attribute: sub
+ custom:
+ authorization-uri: "https://example.com"
+ token-uri: "https://example.com"
+ user-info-uri: "https://example.com"
+ user-name-attribute: sub
+
servlet:
multipart:
# 允许文件上传
@@ -36,9 +89,13 @@ spring:
# url: ...
host: localhost
port: 6379
+
+
mybatis-plus:
global-config:
banner: false
+
+
mioverify:
# 跨域配置,生产环境中必须设置
@@ -48,66 +105,6 @@ mioverify:
# 允许跨域的域名(* 表示允许所有,但生产环境不建议。)
allowed-origin: "https://your-domain.com"
- # Oauth 配置项
- oauth:
- # 是否启用 Oauth 用户注册,注意不启用则无法注册用户,也不能使用 Oauth 登录
- enabled: true
- # OAuth 2 授权码状态有效期
- expire: 5m
- microsoft:
- enabled: false
- # 使用 issuer 自动发现所有端点,这是最推荐的方式
- issuer: "https://login.microsoftonline.com/common/v2.0"
- client-id: "自行填写客户端ID"
- client-secret: "自行填写客户端密钥"
- # 授权成功后的回调地址,GitHub 会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。
- redirect-uri: "http://localhost:8080/oauth/microsoft/callback"
-
- # OIDC 必须包含 openid,并请求基本信息和刷新令牌
- scope: "openid profile email offline_access"
-
- github:
- enabled: false
- # 以下三个内容需自行设置
- client-id: "你的GitHub客户端ID"
- client-secret: "你的GitHub客户端密钥"
- # 授权成功后的回调地址,GitHub 会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。
- redirect-uri: "http://localhost:8080/oauth/github/callback"
- # GitHub 的授权端点 URL
- authorization-url: "https://github.com/login/oauth/authorize"
- # GitHub 的访问令牌端点 URL
- token-url: "https://github.com/login/oauth/access_token"
- # GitHub 的用户信息 API 端点 URL
- user-info-url: "https://api.github.com/user"
- # 向 GitHub 请求的权限范围
- scope: "user:email"
-
- mcjpg:
- enabled: false
- # 使用 issuer URL,自动发现所有端点
- issuer: "https://auth.your-domain.com/realms/your-realm" # OIDC 地址
- client-id: "{OIDC_CLIENT_ID}"
- client-secret: "{OIDC_CLIENT_SECRET}"
- redirect-uri: "http://localhost:8080/oauth/mcjpg/callback" # 回调地址
- # 2. 请求 openid scope,并附带 profile 和 email
- scope: "openid profile email"
-
- custom:
- enabled: false
- # 以下所有内容需自行设置
- client-id: "客户端ID"
- client-secret: "客户端密钥"
- # 授权成功后的回调地址,会将用户重定向至此 URI,并附带授权码。注意域名后的url路径后不能更改。
- redirect-uri: "http://localhost:8080/oauth/custom/callback"
- # 授权端点 URL
- authorization-url: "https://your-auth-server.com/login/oauth/authorize"
- # 访问令牌端点 URL
- token-url: "https://your-auth-server.com/login/oauth/access_token"
- # 用户信息 API 端点 URL
- user-info-url: "https://your-auth-server.com/user"
- # 请求的权限范围
- scope: "user:email"
-
# 扩展API配置项
extern:
register:
@@ -128,6 +125,7 @@ mioverify:
key: admin123098
# 是否允许重复角色名(仅发生在注册和修改)
multi-profile-name: true
+
token:
# 给角色(Profile)签名的文本
signature: 'abcd273nsi179a'
@@ -135,9 +133,11 @@ mioverify:
expire: 10m
# token永久过期时间
invalid: 1h
+
session:
# session过期时间
expire: 6m
+
security:
# 公钥路径
public-key-loc: keys/public.pem
@@ -147,11 +147,13 @@ mioverify:
sign-algorithm: SHA1withRSA
# 批量获取角色(Profile)API最大限制数量
profile-batch-limit: 5
+
texture:
# 材质存储位置
storage-loc: textures
# 默认皮肤存储位置
default-skin-loc: textures/skin/default.png
+
# 服务器元数据,详见Yggdrasil API
props:
meta:
@@ -160,7 +162,7 @@ mioverify:
# 实现的名称
implementation-name: mioverify
# 实现的版本
- implementation-version: '${project.version}'
+ implementation-version: '1.4.0'
links:
# 主页地址
home-page: https://www.baidu.com
diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt
index 5beb060..6856c94 100644
--- a/src/main/resources/banner.txt
+++ b/src/main/resources/banner.txt
@@ -3,4 +3,5 @@
/ /|_/ / / __ \ | | / / _ \/ ___/ / /_/ / / /
/ / / / / /_/ / | |/ / __/ / / / __/ /_/ /
/_/ /_/_/\____/ |___/\___/_/ /_/_/ \__, /
- /____/
\ No newline at end of file
+ /____/
+Spring Boot Version${spring-boot.formatted-version} === https://github.com/pingguomc/MioVerify
\ No newline at end of file
From 9b9a04258456b6223be2d7fc998bd58c03c08875 Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Mon, 23 Feb 2026 11:10:49 +0800
Subject: [PATCH 04/12] =?UTF-8?q?wip:=20Oauth=20=E7=B3=BB=E7=BB=9F=20docs:?=
=?UTF-8?q?=20API=E6=96=87=E6=A1=A3=20test:=20=E6=B5=8B=E8=AF=95=E7=94=A8?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
OAuthAPI.md | 115 ++++++++++++++++++
pom.xml | 5 +-
.../mioverify/config/SecurityConfig.java | 28 ++++-
.../mioverify/controller/OAuthController.java | 13 +-
.../pojo/oauth/OAuthCallbackResp.java | 4 +-
.../org/miowing/mioverify/util/DataUtil.java | 2 +
src/main/resources/application.yml | 12 +-
.../mioverify/MioVerifyApplicationTests.java | 5 +
.../resources/application-test-sqlite.yml | 4 +
9 files changed, 163 insertions(+), 25 deletions(-)
create mode 100644 OAuthAPI.md
create mode 100644 src/test/resources/application-test-sqlite.yml
diff --git a/OAuthAPI.md b/OAuthAPI.md
new file mode 100644
index 0000000..7438377
--- /dev/null
+++ b/OAuthAPI.md
@@ -0,0 +1,115 @@
+# MioVerify API 接口文档
+
+**Yggdrasil-服务端技术规范** 未规定,但在本项目使用的 API 的文档。
+
+## 基本约定
+
+基本上与 **Yggdrasil-服务端技术规范** 保持一致。
+
+TODO: 异常情况和错误类型表格
+
+## OAuth 第三方登录/注册
+
+### OAuth 状态查询
+
+`POST /oauth/status`
+
+用于检查 OAuth 是否启用及可用的提供商列表。
+
+响应格式:
+
+```json5
+{
+ "enabled": true,
+ //ture/false
+ "providers": [
+ "microsoft",
+ "github",
+ "mcjpg",
+ "custom"
+ ]
+ // 已启用的服务商列表
+}
+```
+
+### 授权端点
+
+`GET /oauth/authorize/{provider}`
+
+将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页。前端应通过新窗口或直接跳转的方式访问此端点。
+
+### 回调与重定向端点
+
+`GET /oauth/callback/{provider}`
+
+此端点由 Provider 在用户授权后自动调用,**无需前端直接访问**。
+
+后端除了完毕后会将浏览器重定向至配置的 `OAuth 前端回调地址`,并在 URL **查询** 参数中附加以下信息:
+
+| 参数名 | 类型 | 描述 |
+|:-------------:|:-------:|:------------------------------------------------------|
+| `tempToken` | string | 临时令牌,用于调用 `POST /oauth/authenticate` 换取 `accessToken` |
+| `provider` | string | 使用的 OAuth 提供商名称(如 `github`、`microsoft`) |
+| `needProfile` | boolean | 是否需要用户创建角色 |
+| `userId` | string | 当 `needProfile=true` 时返回用户 ID,便于前端引导创建角色 |
+
+### 登录 Yggdrasil
+
+`POST /oauth/authenticate`
+
+请求格式:
+
+```json5
+{
+ "tempToken": "OAuth 回调端点后返回的临时 Token",
+ "clientToken": "由客户端指定的令牌的 clientToken(可选)",
+ "requestUser": false,
+ // 是否在响应中包含用户信息,默认 false
+}
+```
+
+若请求中未包含 `clientToken`,服务端应该随机生成一个无符号 UUID 作为 `clientToken`。但需要注意 `clientToken`
+可以为任何字符串,即请求中提供任何 `clientToken` 都是可以接受的,不一定要为无符号 UUID。
+
+响应格式:
+
+```json5
+{
+ "accessToken": "令牌的 accessToken",
+ "clientToken": "令牌的 clientToken",
+ "availableProfiles": [
+ // 用户可用角色列表
+ // ,... 每一项为一个角色(格式见 §角色信息的序列化)
+ ],
+ "selectedProfile": {
+ // ... 绑定的角色,若为空,则不需要包含(格式见 §角色信息的序列化)
+ },
+ "user": {
+ // ... 用户信息(仅当请求中 requestUser 为 true 时包含,格式见 §用户信息的序列化)
+ }
+}
+```
+
+### 绑定第三方账号到当前用户
+
+`POST /oauth/bind/{provider}`
+
+请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
+`401 Unauthorized`。
+
+将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页
+
+### 解绑第三方账号
+
+`DELETE /oauth/bind/{provider}`
+
+请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
+`401 Unauthorized`。
+
+若操作成功或从未绑定这个服务商,服务端应返回 HTTP 状态 `204 No Content`
+
+### 获取所有支持的 OAuth 提供商及当前用户的绑定状态(待定)
+
+## 密码管理
+
+## 其他(待定)
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index bdae78a..b2d82fb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.miowing
MioVerify
- 1.3.0-alpha
+ commit-bab54412d55042c93f0822fa831c5aedd4e60a47
MioVerify
A Minecraft verification server implementing Yggdrasil API
@@ -75,8 +75,7 @@
org.springframework.boot
- spring-boot-starter-security-oauth2-client
- 4.0.3
+ spring-boot-starter-oauth2-client
diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
index 4f595db..6d9ae82 100644
--- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -1,6 +1,5 @@
package org.miowing.mioverify.config;
-import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp;
import org.miowing.mioverify.service.OAuthService;
@@ -20,6 +19,7 @@
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.util.Map;
@@ -102,7 +102,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.clientRegistrationRepository(filteredRepository())
.successHandler(oAuth2SuccessHandler())
// 登录失败
- .failureUrl("/oauth/error")
+ .failureHandler((request, response, exception) -> {
+ String frontendErrorUri = dataUtil.getOauthFrontendRedirectUri(); // 可配置
+ String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendErrorUri)
+ .queryParam("error", exception.getMessage())
+ .build().toUriString();
+ response.setStatus(HttpStatus.FOUND.value());
+ response.setHeader("Location", redirectUrl);
+ })
);
}
@@ -146,10 +153,19 @@ private AuthenticationSuccessHandler oAuth2SuccessHandler() {
provider, providerUserId, providerUsername
);
- response.setContentType("application/json;charset=UTF-8");
- response.setStatus(HttpStatus.OK.value());
- // 直接复用 OAuthCallbackResp,Jackson 序列化
- response.getWriter().write(new ObjectMapper().writeValueAsString(resp));
+ String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri(); // 假设有这个方法
+
+ // 构建重定向 URL,添加必要参数
+ String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendRedirectUri)
+ .queryParam("tempToken", resp.getTempToken())
+ .queryParam("provider", provider)
+ .queryParam("needProfile", resp.isNeedProfile())
+ .queryParam("userId", resp.getUserId() != null ? resp.getUserId() : "")
+ .build().toUriString();
+
+ // 发送重定向
+ response.setStatus(HttpStatus.FOUND.value());
+ response.setHeader("Location", redirectUrl);
};
}
diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
index 5ee0237..2315996 100644
--- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java
+++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
@@ -4,7 +4,10 @@
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.miowing.mioverify.dao.UserDao;
-import org.miowing.mioverify.exception.*;
+import org.miowing.mioverify.exception.FeatureNotSupportedException;
+import org.miowing.mioverify.exception.InvalidTokenException;
+import org.miowing.mioverify.exception.NoProfileException;
+import org.miowing.mioverify.exception.UnauthorizedException;
import org.miowing.mioverify.pojo.AToken;
import org.miowing.mioverify.pojo.Profile;
import org.miowing.mioverify.pojo.ProfileShow;
@@ -187,12 +190,4 @@ public ResponseEntity> unbindProvider(
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
- /**
- * OAuth2 登录失败后的跳转端点
- * 由 Spring Security 在 OAuth2 流程失败时自动重定向至此
- */
- @GetMapping("/error")
- public void oauthError() {
- throw new LoginFailedException();
- }
}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java
index 66430a9..1669fab 100644
--- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthCallbackResp.java
@@ -4,8 +4,8 @@
import lombok.experimental.Accessors;
/**
- * OAuth 回调响应
- * 用于 /oauth/callback/{provider} 接口返回临时令牌和用户信息
+ * 并非 OAuth 回调响应 (临时作为数据中转类)
+ * 用于返回临时令牌和用户信息
*/
@Data
@Accessors(chain = true)
diff --git a/src/main/java/org/miowing/mioverify/util/DataUtil.java b/src/main/java/org/miowing/mioverify/util/DataUtil.java
index 05fe3af..0c32cd6 100644
--- a/src/main/java/org/miowing/mioverify/util/DataUtil.java
+++ b/src/main/java/org/miowing/mioverify/util/DataUtil.java
@@ -76,6 +76,8 @@ public class DataUtil implements InitializingBean {
private boolean oAuthCustomEnabled;
@Value("${spring.security.oauth2.expire:5m}")
private Duration oauthExpire;
+ @Value("${mioverify.oauth-frontend-redirect-uri}")
+ private String oauthFrontendRedirectUri;
@Override
public void afterPropertiesSet() throws Exception {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a78d578..d51ecc3 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -33,34 +33,34 @@ spring:
enabled: true
client-id: "your-github-client-id"
client-secret: "your-github-client-secret"
- redirect-uri: "{baseUrl}/oauth/callback/github"
+ redirect-uri: "{baseUrl}/oauth/callback/github" # 不可修改!
scope: read:user, user:email
microsoft:
enabled: true
client-id: "client-id"
client-secret: "client-secret"
- redirect-uri: "{baseUrl}/oauth/callback/microsoft"
+ redirect-uri: "{baseUrl}/oauth/callback/microsoft" # 不可修改!
scope: openid, profile, email
mcjpg:
enabled: true
client-id: "client-id"
client-secret: "client-secret"
- redirect-uri: "{baseUrl}/oauth/callback/mcjpg"
+ redirect-uri: "{baseUrl}/oauth/callback/mcjpg" # 不可修改!
scope: openid, profile, email
custom:
enabled: true
client-id: "client-id"
client-secret: "client-secret"
- redirect-uri: "{baseUrl}/oauth/callback/custom"
+ redirect-uri: "{baseUrl}/oauth/callback/custom" # 不可修改!
scope: read
client-name: "custom"
provider:
microsoft:
- issuer-uri: "https://login.microsoftonline.com/your-tenant-id/v2.0"
+ issuer-uri: "https://login.microsoftonline.com/common/v2.0"
user-name-attribute: name
mcjpg:
issuer-uri: "https://sso.mcjpg.org"
@@ -97,6 +97,8 @@ mybatis-plus:
mioverify:
+ # OAuth 前端回调地址 (全部uri均可修改)
+ oauth-frontend-redirect-uri: "https://your-frontend.com/oauth/callback"
# 跨域配置,生产环境中必须设置
cors:
diff --git a/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java b/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java
index 670f01d..1584794 100644
--- a/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java
+++ b/src/test/java/org/miowing/mioverify/MioVerifyApplicationTests.java
@@ -2,10 +2,15 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
@SpringBootTest
class MioVerifyApplicationTests {
+ @MockBean
+ private ClientRegistrationRepository clientRegistrationRepository;
+
@Test
void contextLoads() {
}
diff --git a/src/test/resources/application-test-sqlite.yml b/src/test/resources/application-test-sqlite.yml
new file mode 100644
index 0000000..c82c25d
--- /dev/null
+++ b/src/test/resources/application-test-sqlite.yml
@@ -0,0 +1,4 @@
+spring:
+ security:
+ oauth2:
+ enabled: false # 禁用 OAuth2
\ No newline at end of file
From 2952e5013816d67cbd2c31f73a5a277f263199f4 Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Wed, 25 Feb 2026 23:25:51 +0800
Subject: [PATCH 05/12] wip: Oauth
---
OAuthAPI.md | 39 +++++-
pom.xml | 2 +-
.../mioverify/config/SecurityConfig.java | 130 +++++++++++++++---
.../mioverify/controller/OAuthController.java | 115 +++++++++++++---
.../listener/TokenExpiredListener.java | 6 +
.../mioverify/pojo/oauth/OAuthBindReq.java | 19 ---
.../pojo/oauth/OAuthProviderListResp.java | 22 ++-
.../mioverify/pojo/oauth/OAuthState.java | 14 --
.../pojo/oauth/OauthUserRegisterReq.java | 26 ----
.../mioverify/service/OAuthService.java | 9 ++
.../mioverify/service/RedisService.java | 19 +++
.../service/impl/OAuthServiceImpl.java | 31 ++++-
.../service/impl/RedisServiceImpl.java | 15 +-
.../java/org/miowing/mioverify/util/Util.java | 5 +-
14 files changed, 342 insertions(+), 110 deletions(-)
delete mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java
delete mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java
delete mode 100644 src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java
diff --git a/OAuthAPI.md b/OAuthAPI.md
index 7438377..a1a9e33 100644
--- a/OAuthAPI.md
+++ b/OAuthAPI.md
@@ -6,8 +6,6 @@
基本上与 **Yggdrasil-服务端技术规范** 保持一致。
-TODO: 异常情况和错误类型表格
-
## OAuth 第三方登录/注册
### OAuth 状态查询
@@ -44,7 +42,7 @@ TODO: 异常情况和错误类型表格
此端点由 Provider 在用户授权后自动调用,**无需前端直接访问**。
-后端除了完毕后会将浏览器重定向至配置的 `OAuth 前端回调地址`,并在 URL **查询** 参数中附加以下信息:
+后端处理完毕后会将浏览器重定向至配置的 `OAuth 前端回调地址`,并在 URL **查询** 参数中附加以下信息:
| 参数名 | 类型 | 描述 |
|:-------------:|:-------:|:------------------------------------------------------|
@@ -94,8 +92,7 @@ TODO: 异常情况和错误类型表格
`POST /oauth/bind/{provider}`
-请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
-`401 Unauthorized`。
+TODO
将被 `302` 重定向至指定的 Provider ( Authorization Server )的授权页
@@ -106,9 +103,39 @@ TODO: 异常情况和错误类型表格
请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
`401 Unauthorized`。
+若用户仅剩一个第三方认证,则解绑失败,返回 `403 Forbidden`
+
若操作成功或从未绑定这个服务商,服务端应返回 HTTP 状态 `204 No Content`
-### 获取所有支持的 OAuth 提供商及当前用户的绑定状态(待定)
+### 获取所有支持的 OAuth 提供商及当前用户的绑定状态
+
+`GET /oauth/providers`
+
+请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
+`401 Unauthorized`。
+
+响应格式:
+
+```json5
+{
+ "providers": [
+ {
+ "provider": "唯一标识符", // 例如 github
+ "bond": true // true or false 表示是否绑定
+ },
+ // 可以包含更多
+ ]
+}
+```
+
+### 登出
+
+`POST /oauth/signout`
+
+请求需要带上 HTTP 头部 `Authorization: Bearer {accessToken}` 进行认证。若未包含 Authorization 头或 accessToken 无效,则返回
+`401 Unauthorized`。
+
+若操作成功,服务端应返回 HTTP 状态 `204 No Content`。
## 密码管理
diff --git a/pom.xml b/pom.xml
index b2d82fb..68e1bf4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.miowing
MioVerify
- commit-bab54412d55042c93f0822fa831c5aedd4e60a47
+ commit-9b9a04258456b6223be2d7fc998bd58c03c08875
MioVerify
A Minecraft verification server implementing Yggdrasil API
diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
index 6d9ae82..142ceeb 100644
--- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -3,6 +3,7 @@
import lombok.extern.slf4j.Slf4j;
import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp;
import org.miowing.mioverify.service.OAuthService;
+import org.miowing.mioverify.service.RedisService;
import org.miowing.mioverify.util.DataUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
@@ -16,9 +17,13 @@
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
+import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
@@ -31,7 +36,7 @@
* 本类承担以下职责:
*
* - 禁用 CSRF(项目为无状态 REST API,使用自定义 Token 鉴权)
- * - 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 自行处理)
+ * - 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 处理)
* - 根据配置文件中的开关,动态注册启用的 OAuth2 Provider
* - 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}} 两个端点,
* 由 Spring Security 自动完成授权跳转与 code 换 token 流程
@@ -54,6 +59,8 @@ public class SecurityConfig {
@Autowired
private OAuthService oAuthService;
@Autowired
+ private RedisService redisService;
+ @Autowired
private DataUtil dataUtil;
@@ -93,6 +100,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 接管 /oauth/authorize/{provider}
.authorizationEndpoint(auth -> auth
.baseUri("/oauth/authorize")
+ .authorizationRequestResolver(buildAuthorizationRequestResolver())
)
// 接管 /oauth/callback/{provider}
.redirectionEndpoint(redirect -> redirect
@@ -144,31 +152,94 @@ private AuthenticationSuccessHandler oAuth2SuccessHandler() {
// 不同 Provider 的用户名字段名不同,按优先级取
String providerUserId = oAuth2User.getName();
String providerUsername = resolveUsername(oAuth2User);
+ String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri();
- log.info("OAuth2 authorization success: provider={}, userId={}, username={}",
- provider, providerUserId, providerUsername);
+ // 从 state 参数里尝试提取 bindNonce
+ String state = request.getParameter("state");
+ String bindNonce = extractBindNonce(state);
- // 交给 OAuthService 处理用户查找/创建,返回临时 Token
- OAuthCallbackResp resp = oAuthService.handleOAuthLogin(
- provider, providerUserId, providerUsername
- );
+ if ( bindNonce != null ) {
+ // 绑定流程
+ String userId = redisService.consumeOAuthBindNonce(bindNonce);
+
+ if ( userId == null ) {
+ // nonce 已过期或不存在
+ redirect(response, frontendRedirectUri, "error", "bind_nonce_expired");
+ return;
+ }
+
+ try {
+ oAuthService.handleOAuthBind(userId, provider, providerUserId);
+ } catch (Exception e) {
+ log.warn("OAuth bind failed: {}", e.getClass().getSimpleName());
+ redirect(response, frontendRedirectUri, "error", "bind_failed");
+ return;
+ }
+
+ log.info("OAuth2 bind success: provider={}, userId={}", provider, userId);
+ redirect(response, frontendRedirectUri, "bindSuccess", "true");
+
+ } else {
+ log.info("OAuth2 authorization success: provider={}, userId={}, username={}",
+ provider, providerUserId, providerUsername);
- String frontendRedirectUri = dataUtil.getOauthFrontendRedirectUri(); // 假设有这个方法
+ // 交给 OAuthService 处理用户查找/创建,返回临时 Token
+ OAuthCallbackResp resp = oAuthService.handleOAuthLogin(
+ provider, providerUserId, providerUsername
+ );
- // 构建重定向 URL,添加必要参数
- String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendRedirectUri)
- .queryParam("tempToken", resp.getTempToken())
- .queryParam("provider", provider)
- .queryParam("needProfile", resp.isNeedProfile())
- .queryParam("userId", resp.getUserId() != null ? resp.getUserId() : "")
- .build().toUriString();
+ // 构建重定向 URL,添加必要参数
+ String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontendRedirectUri)
+ .queryParam("tempToken", resp.getTempToken())
+ .queryParam("provider", provider)
+ .queryParam("needProfile", resp.isNeedProfile())
+ .queryParam("userId", resp.getUserId() != null ? resp.getUserId() : "")
+ .build().toUriString();
- // 发送重定向
- response.setStatus(HttpStatus.FOUND.value());
- response.setHeader("Location", redirectUrl);
+ // 发送重定向
+ response.setStatus(HttpStatus.FOUND.value());
+ response.setHeader("Location", redirectUrl);
+ }
};
}
+
+ /**
+ * 自定义授权请求解析器。
+ * 作用:当请求 /oauth/authorize/{provider}?bind_nonce=xxx 时,
+ * 把 bind_nonce 附加到 OAuth state 里,格式为:
+ * "原始state,bindnonce:xxxxx"
+ * successHandler 收到回调后,从 state 里读出 nonce,识别是绑定模式。
+ */
+ private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver() {
+ DefaultOAuth2AuthorizationRequestResolver resolver =
+ new DefaultOAuth2AuthorizationRequestResolver(
+ clientRegistrationRepository,
+ "/oauth/authorize"
+ );
+
+ resolver.setAuthorizationRequestCustomizer(customizer -> {
+ // 从当前请求里取 bind_nonce 参数
+ RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
+ if ( attributes == null ) return;
+
+ jakarta.servlet.http.HttpServletRequest request =
+ (jakarta.servlet.http.HttpServletRequest)
+ attributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
+ if ( request == null ) return;
+
+ String bindNonce = request.getParameter("bind_nonce");
+ if ( bindNonce != null && ! bindNonce.isBlank() ) {
+ // 附加到 state 末尾,以逗号分隔
+ String originalState = customizer.build().getState();
+ customizer.state(originalState + ",bindnonce:" + bindNonce);
+ }
+ });
+
+ return resolver;
+ }
+
+
/**
* 根据不同 Provider 解析用户名。
*
@@ -221,4 +292,27 @@ private ClientRegistrationRepository filteredRepository() {
return new InMemoryClientRegistrationRepository(activeList);
}
+ /** 从 state 中提取 bindNonce,格式:<原始state>,bindnonce: */
+ private String extractBindNonce(String state) {
+ if ( state == null ) return null;
+ for ( String part : state.split(",") ) {
+ if ( part.startsWith("bindnonce:") ) {
+ return part.substring("bindnonce:".length());
+ }
+ }
+ return null;
+ }
+
+ /** 工具方法:重定向并带一个查询参数 */
+ private void redirect(
+ jakarta.servlet.http.HttpServletResponse response,
+ String baseUrl, String key, String value) throws java.io.IOException {
+
+ String url = UriComponentsBuilder.fromHttpUrl(baseUrl)
+ .queryParam(key, value)
+ .build().toUriString();
+ response.setStatus(HttpStatus.FOUND.value());
+ response.setHeader("Location", url);
+ }
+
}
diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
index 2315996..445cd1f 100644
--- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java
+++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
@@ -1,7 +1,7 @@
package org.miowing.mioverify.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import jakarta.servlet.http.HttpServletRequest;
+import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.miowing.mioverify.dao.UserDao;
import org.miowing.mioverify.exception.FeatureNotSupportedException;
@@ -13,7 +13,6 @@
import org.miowing.mioverify.pojo.ProfileShow;
import org.miowing.mioverify.pojo.User;
import org.miowing.mioverify.pojo.oauth.OAuthAuthReq;
-import org.miowing.mioverify.pojo.oauth.OAuthBindReq;
import org.miowing.mioverify.pojo.oauth.OAuthProviderListResp;
import org.miowing.mioverify.pojo.oauth.OAuthStatusResp;
import org.miowing.mioverify.pojo.response.AuthResp;
@@ -31,9 +30,10 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
/**
- * OAuth 2.0 / OIDC 相关控制器
+ * OAuth 2.0 控制器
* 所有端点均以 /oauth 开头,处理第三方登录、绑定、解绑等操作
*/
@Slf4j
@@ -46,22 +46,17 @@ public class OAuthController {
@Autowired
private UserService userService;
-
@Autowired
private ProfileService profileService;
-
@Autowired
private RedisService redisService;
-
@Autowired
private OAuthService oAuthService;
@Autowired
private DataUtil dataUtil;
-
@Autowired
private TokenUtil tokenUtil;
-
@Autowired
private Util util;
@@ -76,7 +71,7 @@ public OAuthStatusResp status() {
}
List providers = new ArrayList<>();
if ( dataUtil.isOAuthMicrosoftEnabled() ) providers.add("microsoft");
- if ( dataUtil.isOAuthGitHubEnabled() ) providers.add("gitHub");
+ if ( dataUtil.isOAuthGitHubEnabled() ) providers.add("github");
if ( dataUtil.isOAuthMcjpgEnabled() ) providers.add("mcjpg");
if ( dataUtil.isOAuthCustomEnabled() ) providers.add("custom");
return new OAuthStatusResp().setEnabled(true).setProviders(providers);
@@ -129,36 +124,83 @@ public AuthResp authenticate(@RequestBody OAuthAuthReq req) {
* 获取所有支持的 OAuth 提供商及当前用户的绑定状态
*/
@GetMapping("/providers")
- public OAuthProviderListResp getProviders(HttpServletRequest request) {
+ public OAuthProviderListResp getProviders(@RequestHeader("Authorization") String authorization) {
if ( ! dataUtil.isOAuthEnabled() ) {
throw new FeatureNotSupportedException();
}
- //TODO
+ User user = getUserFromAuthorization(authorization);
- return null;
+ List providerInfos = new ArrayList<>();
+
+ if ( dataUtil.isOAuthMicrosoftEnabled() ) {
+ providerInfos.add(new OAuthProviderListResp.ProviderInfo()
+ .setProvider("microsoft")
+ .setBound(user.getMicrosoftId() != null));
+ }
+ if ( dataUtil.isOAuthGitHubEnabled() ) {
+ providerInfos.add(new OAuthProviderListResp.ProviderInfo()
+ .setProvider("github")
+ .setBound(user.getGithubId() != null));
+ }
+ if ( dataUtil.isOAuthMcjpgEnabled() ) {
+ providerInfos.add(new OAuthProviderListResp.ProviderInfo()
+ .setProvider("mcjpg")
+ .setBound(user.getMcjpgId() != null));
+ }
+ if ( dataUtil.isOAuthCustomEnabled() ) {
+ providerInfos.add(new OAuthProviderListResp.ProviderInfo()
+ .setProvider("custom")
+ .setBound(user.getCustomId() != null));
+ }
+
+ return new OAuthProviderListResp().setProviders(providerInfos);
}
/**
* 绑定第三方账号到当前用户(需要登录)
+ *
+ * 1. 校验用户 Token
+ * 2. 生成 bindNonce 存入 Redis
+ * 3. 返回带有 bind_nonce 参数的 OAuth 授权 URL
+ * 4. 前端拿到 URL 后直接跳转,剩下的全交给 Spring Security
*/
@PostMapping("/bind/{provider}")
public ResponseEntity> bindProvider(
@PathVariable String provider,
- @RequestBody OAuthBindReq req,
- HttpServletRequest request) {
+ @RequestHeader("Authorization") String authorization) {
if ( ! dataUtil.isOAuthEnabled() ) {
throw new FeatureNotSupportedException();
}
- //TODO
+ boolean providerEnabled = switch ( provider ) {
+ case "github" -> dataUtil.isOAuthGitHubEnabled();
+ case "microsoft" -> dataUtil.isOAuthMicrosoftEnabled();
+ case "mcjpg" -> dataUtil.isOAuthMcjpgEnabled();
+ case "custom" -> dataUtil.isOAuthCustomEnabled();
+ default -> false;
+ };
+ if ( ! providerEnabled ) {
+ throw new FeatureNotSupportedException();
+ }
+
+ User user = getUserFromAuthorization(authorization);
+ String nonce = Util.genUUID();
+ redisService.saveOAuthBindNonce(nonce, user.getId());
- return ResponseEntity.ok().build();
+ String serverUrl = (dataUtil.isUseHttps() ? "https://" : "http://")
+ + dataUtil.getServerDomain() + ":" + dataUtil.getPort();
+ String authUrl = serverUrl + "/oauth/authorize/" + provider
+ + "?bind_nonce=" + nonce;
+
+ log.info("User {} initiate bind for provider: {}", user.getId(), provider);
+
+ return ResponseEntity.ok(Map.of("authorizationUrl", authUrl));
}
/**
- * 解绑第三方账号
+ * 解绑第三方账号(需要登录)
*/
@DeleteMapping("/bind/{provider}")
public ResponseEntity> unbindProvider(
@@ -168,6 +210,41 @@ public ResponseEntity> unbindProvider(
throw new FeatureNotSupportedException();
}
+ User user = getUserFromAuthorization(authorization);
+
+ oAuthService.unbind(user.getId(), provider); // 取消成功和未绑定均返回 204
+
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 登出方法
+ * //TODO 临时设置,后续更改为更合适的登出方法
+ *
+ * @see AuthController
+ */
+ @Deprecated
+ @PostMapping("/signout")
+ public ResponseEntity> signOut(@RequestHeader("Authorization") String authorization) {
+ if ( ! dataUtil.isOAuthEnabled() ) {
+ throw new FeatureNotSupportedException();
+ }
+
+ User user = getUserFromAuthorization(authorization);
+ redisService.clearToken(user.getId());
+ log.info("Oauth logout: {}", user.getUsername());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+
+ /**
+ * 从 Authorization 头提供的 accessToken 解析用户
+ *
+ * @param authorization Http 请求 Authorization头
+ *
+ * @return {@link User} 实例
+ */
+ private @NonNull User getUserFromAuthorization(String authorization) {
if ( authorization == null || ! authorization.startsWith("Bearer ") ) {
throw new UnauthorizedException(); // 401
}
@@ -185,9 +262,7 @@ public ResponseEntity> unbindProvider(
throw new UnauthorizedException(); // 401
}
- oAuthService.unbind(user.getId(), provider); // 取消成功和未绑定均返回 204
-
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ return user;
}
}
diff --git a/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java b/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java
index 9acd231..bc4538f 100644
--- a/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java
+++ b/src/main/java/org/miowing/mioverify/listener/TokenExpiredListener.java
@@ -10,6 +10,7 @@
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
+
import java.nio.charset.StandardCharsets;
/**
@@ -23,14 +24,18 @@
public class TokenExpiredListener extends KeyExpirationEventMessageListener {
@Autowired
private StringRedisTemplate redisTemplate;
+
public TokenExpiredListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
+ //
}
+
@Override
public void afterPropertiesSet() throws Exception {
log.info("Tokens expired Listener hooked.");
super.afterPropertiesSet();
}
+
@Override
protected void doHandleMessage(Message message) {
String key = new String(message.getBody(), StandardCharsets.UTF_8);
@@ -49,4 +54,5 @@ protected void doHandleMessage(Message message) {
}
super.doHandleMessage(message);
}
+
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java
deleted file mode 100644
index daeab4f..0000000
--- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthBindReq.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.miowing.mioverify.pojo.oauth;
-
-import lombok.Data;
-
-
-/**
- * 手动绑定第三方账号的请求体
- * 用于 POST /oauth/{provider}/bind
- */
-@Data
-public class OAuthBindReq {
-
- /** Provider 返回的用户唯一 ID */
- private String providerUserId;
-
- /** Provider 返回的用户名 */
- private String providerUsername;
-
-}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java
index 6078559..d7e1936 100644
--- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthProviderListResp.java
@@ -1,5 +1,25 @@
package org.miowing.mioverify.pojo.oauth;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.util.List;
+
+@Data
+@Accessors(chain = true)
public class OAuthProviderListResp {
-}
+ private List providers;
+
+ @Data
+ @Accessors(chain = true)
+ public static class ProviderInfo {
+
+ /** Provider 名称 */
+ private String provider;
+ /** 当前用户是否已绑定该 Provider */
+ private boolean bound;
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java
deleted file mode 100644
index b499d72..0000000
--- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthState.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.miowing.mioverify.pojo.oauth;
-
-import lombok.Data;
-import org.springframework.lang.Nullable;
-
-@Data
-public class OAuthState {
-
- private String provider;
- private boolean bind; // 是否为绑定模式
- private @Nullable String userId; // 绑定模式下的当前用户ID
- private String redirectUri; // 登录成功后重定向地址
-
-}
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java
deleted file mode 100644
index 99a4d13..0000000
--- a/src/main/java/org/miowing/mioverify/pojo/oauth/OauthUserRegisterReq.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.miowing.mioverify.pojo.oauth;
-
-import lombok.Data;
-import org.springframework.lang.Nullable;
-
-@Data
-public class OauthUserRegisterReq {
-
- private String username;
-
- private @Nullable String password; // 密码可为空
-
- private String preferredLang = "zh_CN";
-
- private String key;
-
- // OAuth相关字段
-
- private @Nullable String microsoftId;
-
- private @Nullable String githubId;
-
- private @Nullable String mcjpgId;
-
- private @Nullable String customId;
-}
diff --git a/src/main/java/org/miowing/mioverify/service/OAuthService.java b/src/main/java/org/miowing/mioverify/service/OAuthService.java
index 22cb616..4c19526 100644
--- a/src/main/java/org/miowing/mioverify/service/OAuthService.java
+++ b/src/main/java/org/miowing/mioverify/service/OAuthService.java
@@ -25,4 +25,13 @@ public interface OAuthService {
*/
void unbind(String userId, String provider);
+ /**
+ * 将 OAuth provider 账号绑定到已有本地用户。
+ *
+ * @param userId 要绑定到的本地用户 ID(从 nonce 中取出)
+ * @param provider provider 名称
+ * @param providerUserId provider 返回的用户唯一 ID
+ */
+ void handleOAuthBind(String userId, String provider, String providerUserId);
+
}
diff --git a/src/main/java/org/miowing/mioverify/service/RedisService.java b/src/main/java/org/miowing/mioverify/service/RedisService.java
index daeac14..26a0bd7 100644
--- a/src/main/java/org/miowing/mioverify/service/RedisService.java
+++ b/src/main/java/org/miowing/mioverify/service/RedisService.java
@@ -8,6 +8,7 @@ public interface RedisService {
void clearToken(String userId);
void saveSession(String serverId, String token);
+
/**
* 保存 OAuth 临时令牌,用于后续换取正式的 accessToken。
* 临时令牌需设置较短的过期时间,防止滥用。
@@ -26,4 +27,22 @@ public interface RedisService {
*/
String consumeOAuthTempToken(String tempToken);
+ /**
+ * 保存 OAuth 绑定 Nonce,用于绑定流程中将 nonce 与 userId 关联。
+ * 设置与 oauthExpire 一致的过期时间,超时自动失效。
+ *
+ * @param nonce 随机生成的绑定标识符(UUID)
+ * @param userId 发起绑定的用户 ID
+ */
+ void saveOAuthBindNonce(String nonce, String userId);
+
+ /**
+ * 消费 OAuth 绑定 Nonce(一次性原子操作)。
+ * 取出对应的用户 ID 后立即删除,防止重放攻击。
+ *
+ * @param nonce 待消费的 nonce
+ *
+ * @return 对应的用户 ID;若不存在、已过期或已被消费,则返回 null
+ */
+ String consumeOAuthBindNonce(String nonce);
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
index b7be4dc..562dd93 100644
--- a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
+++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
@@ -4,6 +4,7 @@
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.miowing.mioverify.dao.UserDao;
+import org.miowing.mioverify.exception.DuplicateUserNameException;
import org.miowing.mioverify.exception.FeatureNotSupportedException;
import org.miowing.mioverify.pojo.User;
import org.miowing.mioverify.pojo.oauth.OAuthCallbackResp;
@@ -91,7 +92,6 @@ public OAuthCallbackResp handleOAuthLogin(
.setNeedProfile(false); //暂时设置为 false
}
-
@Override
public void unbind(@NonNull String userId, @NonNull String provider) {
BiConsumer setter = PROVIDER_SETTER.get(provider);
@@ -108,9 +108,36 @@ public void unbind(@NonNull String userId, @NonNull String provider) {
log.info("User: {} ,Unbind oauth provider: {}", userId, provider);
}
+ @Override
+ public void handleOAuthBind(String userId, String provider, String providerUserId) {
- //-----------
+ BiConsumer setter = PROVIDER_SETTER.get(provider);
+ String fieldName = PROVIDER_FIELD.get(provider);
+ if ( setter == null || fieldName == null ) {
+ throw new FeatureNotSupportedException();
+ }
+
+ // 检查该第三方账号是否已被其他本地账号绑定
+ User existing = userDao.selectOne(
+ new QueryWrapper().eq(fieldName, providerUserId)
+ );
+ if ( existing != null && ! existing.getId().equals(userId) ) {
+ throw new DuplicateUserNameException(); // 已被其他账号绑定
+ }
+
+ User user = userDao.selectById(userId);
+ if ( user == null ) {
+ throw new FeatureNotSupportedException();
+ }
+
+ setter.accept(user, providerUserId);
+ userDao.updateById(user);
+
+ log.info("Oauth User {} bind provider {} -> {}", userId, provider, providerUserId);
+ }
+
+ //-----------
/**
* 创建新用户
diff --git a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
index ac25ffe..32e5bbe 100644
--- a/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
+++ b/src/main/java/org/miowing/mioverify/service/impl/RedisServiceImpl.java
@@ -23,9 +23,8 @@ public class RedisServiceImpl implements RedisService {
@Autowired
private DataUtil dataUtil;
-
- private static final String OAUTH_STATE_PREF = "oauth:state:";
private static final String OAUTH_TEMP_TOKEN_PREF = "oauth:temp:";
+ private static final String OAUTH_BIND_NONCE_PREF = "oauth:bind:";
@Override
public void saveToken(String token, String userId) {
@@ -69,6 +68,7 @@ public void saveSession(String serverId, String token) {
redisTemplate.opsForValue().set(SessionUtil.SESSION_PREF + serverId, token, dataUtil.getSessionExpire());
}
+
@Override
public void saveOAuthTempToken(String tempToken, String userId) {
String key = OAUTH_TEMP_TOKEN_PREF + tempToken;
@@ -81,4 +81,15 @@ public String consumeOAuthTempToken(String tempToken) {
return redisTemplate.opsForValue().getAndDelete(key);
}
+ @Override
+ public void saveOAuthBindNonce(String nonce, String userId) {
+ String key = OAUTH_BIND_NONCE_PREF + nonce;
+ redisTemplate.opsForValue().set(key, userId, dataUtil.getOauthExpire().getSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Override
+ public String consumeOAuthBindNonce(String nonce) {
+ return redisTemplate.opsForValue().getAndDelete(OAUTH_BIND_NONCE_PREF + nonce);
+ }
+
}
\ No newline at end of file
diff --git a/src/main/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java
index 4e31cc4..925f32f 100644
--- a/src/main/java/org/miowing/mioverify/util/Util.java
+++ b/src/main/java/org/miowing/mioverify/util/Util.java
@@ -9,6 +9,7 @@
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
+
import java.io.IOException;
import java.security.*;
import java.util.Base64;
@@ -51,7 +52,9 @@ public String getTextureURL(String hash) {
return getServerURL() + "/texture/hash/" + hash;
}
public String getServerURL() {
- return dataUtil.isUseHttps() ? "https://" : "http://" + dataUtil.getServerDomain() + ":" + dataUtil.getPort();
+ //TODO 需要优化CDN
+ return (dataUtil.isUseHttps() ? "https://" : "http://")
+ + dataUtil.getServerDomain() + ":" + dataUtil.getPort();
}
public String signature(String value) {
try {
From 0e3997a924de0816ce0cf04c3dd2a0327ef928ed Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Thu, 26 Feb 2026 08:53:06 +0800
Subject: [PATCH 06/12] =?UTF-8?q?perf:=20URL=20=E6=94=B9=E4=B8=BA=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E8=AF=86=E5=88=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/java/org/miowing/mioverify/util/Util.java | 6 +++---
src/main/resources/application.yml | 2 ++
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/main/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java
index 925f32f..b77cf46 100644
--- a/src/main/java/org/miowing/mioverify/util/Util.java
+++ b/src/main/java/org/miowing/mioverify/util/Util.java
@@ -9,6 +9,7 @@
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.IOException;
import java.security.*;
@@ -52,9 +53,8 @@ public String getTextureURL(String hash) {
return getServerURL() + "/texture/hash/" + hash;
}
public String getServerURL() {
- //TODO 需要优化CDN
- return (dataUtil.isUseHttps() ? "https://" : "http://")
- + dataUtil.getServerDomain() + ":" + dataUtil.getPort();
+ // TODO
+ return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
}
public String signature(String value) {
try {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d51ecc3..bce325d 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -17,6 +17,8 @@ server:
key-alias: boot
servlet:
application-display-name: MioVerify Alpha
+ # 自动适配 Nginx 和 Cloudflare
+ forward-headers-strategy: framework
spring:
From 0bcfa8221c049a599ba29e1eba10ed17108b39ad Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:56:50 +0800
Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=E5=BE=AE=E8=BD=AF=E9=AA=8C?=
=?UTF-8?q?=E8=AF=81=E9=87=8D=E5=A4=8D=E8=BF=9B=E8=A1=8C=E9=94=99=E8=AF=AF?=
=?UTF-8?q?=E7=94=B3=E8=AF=B7=20style:=20TODO=20=E6=B2=A1=E6=9C=89?=
=?UTF-8?q?=E5=8E=BB=E6=8E=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../mioverify/config/SecurityConfig.java | 171 +++++++++++-------
.../java/org/miowing/mioverify/util/Util.java | 1 -
.../resources/application-test-sqlite.yml | 4 -
3 files changed, 105 insertions(+), 71 deletions(-)
diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
index 142ceeb..aba9daf 100644
--- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -6,6 +6,8 @@
import org.miowing.mioverify.service.RedisService;
import org.miowing.mioverify.util.DataUtil;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
+import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
@@ -28,7 +30,6 @@
import java.util.List;
import java.util.Map;
-import java.util.Objects;
/**
* Spring Security 全局安全配置类
@@ -36,26 +37,37 @@
* 本类承担以下职责:
*
* - 禁用 CSRF(项目为无状态 REST API,使用自定义 Token 鉴权)
- * - 放行所有接口(鉴权逻辑由各 Controller 内部通过 TokenUtil 处理)
- * - 根据配置文件中的开关,动态注册启用的 OAuth2 Provider
- * - 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}} 两个端点,
- * 由 Spring Security 自动完成授权跳转与 code 换 token 流程
+ * - 放行所有接口
+ * - 根据配置文件中各 Provider 的 {@code enabled} 开关,在 Spring 容器启动阶段
+ * 提前过滤,仅将已启用的 Provider 注册进
+ * {@link ClientRegistrationRepository},避免 Spring Boot 自动配置在启动时
+ * 请求所有 Provider 的 {@code issuer-uri}(主要是微软)导致启动失败
+ * - 接管 {@code /oauth/authorize/{provider}} 和 {@code /oauth/callback/{provider}}
+ * 两个端点,由 Spring Security 自动完成授权跳转与 code 换 token 流程
* - OAuth2 登录成功后,通过 {@code successHandler} 调用业务层完成用户查找/创建并签发临时 Token
*
*
+ * 与原有账密登录的关系
* 与原有账密登录({@code /authserver/**})完全独立,互不影响。
+ *
+ * 启动顺序说明
+ *
+ * - {@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 {
- /**
- * Spring 自动装配的 ClientRegistrationRepository。
- * 包含配置文件中 Provider 的注册信息(无论是否启用)。
- */
+ /** OAuth2 客户端配置,从 {@code application.yml} 自动绑定。包含所有 Provider 的原始配置(无论是否启用)。 */
@Autowired
- private ClientRegistrationRepository clientRegistrationRepository;
+ private OAuth2ClientProperties oAuth2ClientProperties;
@Autowired
private OAuthService oAuthService;
@Autowired
@@ -63,6 +75,57 @@ public class SecurityConfig {
@Autowired
private DataUtil dataUtil;
+ /**
+ * 创建仅包含已启用 Provider 的 {@link ClientRegistrationRepository} Bean。
+ *
+ * 避免启动时因请求无效的 {@code issuer-uri} 而抛出异常。
+ *
+ *
+ * - 读取 {@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 过滤链。
@@ -75,14 +138,14 @@ public class SecurityConfig {
* - 若 OAuth 总开关开启,注册 OAuth2 登录流程
*
*
- * @param http Spring Security 的 HttpSecurity 构造器
- *
+ * @param http Spring Security 的 HttpSecurity 构造器
+ * @param clientRegistrationRepository 已过滤的 Provider 注册仓库,由 {@link #clientRegistrationRepository()} 提供
* @return 构建完成的 SecurityFilterChain
*
* @throws Exception 配置异常
*/
@Bean
- public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception {
http
.csrf(AbstractHttpConfigurer :: disable)
@@ -100,14 +163,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 接管 /oauth/authorize/{provider}
.authorizationEndpoint(auth -> auth
.baseUri("/oauth/authorize")
- .authorizationRequestResolver(buildAuthorizationRequestResolver())
+ .authorizationRequestResolver(buildAuthorizationRequestResolver(clientRegistrationRepository))
)
// 接管 /oauth/callback/{provider}
.redirectionEndpoint(redirect -> redirect
.baseUri("/oauth/callback/*")
)
// 只注册配置文件中 enabled=true 的 Provider
- .clientRegistrationRepository(filteredRepository())
+ .clientRegistrationRepository(clientRegistrationRepository)
.successHandler(oAuth2SuccessHandler())
// 登录失败
.failureHandler((request, response, exception) -> {
@@ -130,18 +193,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
* 当 Spring Security 完成以下工作后,本处理器被调用:
*
* - 用 code 向 Provider 换取 Access Token
- * - 用 Access Token ( OAuth 的 Token ) 调用 UserInfo 端点获取用户信息
+ * - 用 Access Token 调用 UserInfo 端点获取用户信息
*
*
* 本处理器负责:
*
- * - 从 {@link OAuth2User} 中提取 providerUserId 和 providerUsername
- * - 调用 {@link org.miowing.mioverify.service.OAuthService#handleOAuthLogin} 完成用户查找或创建
- * - 签发临时 Token,返回给前端
- * - 前端用临时 Token 调用 {@code POST /oauth/authenticate} 换取正式 accessToken
+ * - 从 {@link OAuth2User} 中提取 {@code providerUserId} 和 {@code providerUsername}
+ * - 从 {@code state} 参数中识别是否为账号绑定流程(携带 {@code bindnonce})
+ * - 绑定流程:校验 nonce 有效性,调用 {@link OAuthService#handleOAuthBind} 完成绑定
+ * - 登录流程:调用 {@link OAuthService#handleOAuthLogin} 完成用户查找或创建,
+ * 签发临时 Token 并重定向前端
*
*
- * @return {@link AuthenticationSuccessHandler} 实例
+ * @return {@link AuthenticationSuccessHandler} 实例
*/
private AuthenticationSuccessHandler oAuth2SuccessHandler() {
return (request, response, authentication) -> {
@@ -205,13 +269,19 @@ private AuthenticationSuccessHandler oAuth2SuccessHandler() {
/**
- * 自定义授权请求解析器。
- * 作用:当请求 /oauth/authorize/{provider}?bind_nonce=xxx 时,
- * 把 bind_nonce 附加到 OAuth state 里,格式为:
- * "原始state,bindnonce:xxxxx"
- * successHandler 收到回调后,从 state 里读出 nonce,识别是绑定模式。
+ * 构建自定义授权请求解析器。
+ *
+ * 当请求 {@code /oauth/authorize/{provider}?bind_nonce=xxx} 时,
+ * 将 {@code bind_nonce} 附加到 OAuth {@code state} 参数中,格式为:
+ * {@code 原始state,bindnonce:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
+ *
+ * {@link #oAuth2SuccessHandler()} 收到回调后,通过 {@link #extractBindNonce}
+ * 从 {@code state} 中解析出 nonce,识别当前是绑定模式而非登录模式。
+ *
+ * @param clientRegistrationRepository 已过滤的 Provider 注册仓库
+ * @return 配置完成的 {@link OAuth2AuthorizationRequestResolver}
*/
- private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver() {
+ private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) {
DefaultOAuth2AuthorizationRequestResolver resolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
@@ -243,14 +313,15 @@ private OAuth2AuthorizationRequestResolver buildAuthorizationRequestResolver() {
/**
* 根据不同 Provider 解析用户名。
*
- *
- * - GitHub:使用 {@code login} 字段
- * - OIDC(mcjpg / microsoft):使用 {@code preferred_username} 或 {@code name}
- *
+ * 各 Provider 返回的用户名字段不同,按以下优先级依次尝试:
+ *
+ * - {@code login}:GitHub 专用字段
+ * - {@code displayName}:MCJPG 专用字段
+ * - {@code name}:Microsoft / 其他 OIDC Provider 的通用字段
+ *
*
* @param oAuth2User Spring Security 封装的 OAuth2 用户对象
- *
- * @return 解析到的用户名,可能为 null
+ * @return 解析到的用户名,若所有字段均为空则返回 {@code null}
*/
private String resolveUsername(OAuth2User oAuth2User) {
if ( oAuth2User.getAttribute("login") != null )
@@ -260,39 +331,7 @@ private String resolveUsername(OAuth2User oAuth2User) {
return oAuth2User.getAttribute("name"); // Microsoft / 其他
}
- /**
- * 根据配置文件中各 Provider 的 {@code enabled} 开关,
- * 过滤出实际启用的 Provider,构建新的 {@link ClientRegistrationRepository}。
- *
- * 未启用的 Provider 不会参与 OAuth2 流程,
- * 即使配置文件中写了 client-id / client-secret 也不会生效。
- *
- * @return 仅包含已启用 Provider 的 ClientRegistrationRepository
- */
- private ClientRegistrationRepository filteredRepository() {
- Map enabledMap = Map.of(
- "github", dataUtil.isOAuthGitHubEnabled(),
- "microsoft", dataUtil.isOAuthMicrosoftEnabled(),
- "mcjpg", dataUtil.isOAuthMcjpgEnabled(),
- "custom", dataUtil.isOAuthCustomEnabled()
- );
-
- List activeList = enabledMap.entrySet().stream()
- .filter(Map.Entry :: getValue)
- .map(e -> clientRegistrationRepository.findByRegistrationId(e.getKey()))
- .filter(Objects :: nonNull)
- .peek(r -> log.info("OAuth2 Provider enabled: {}", r.getRegistrationId()))
- .toList();
-
- if ( activeList.isEmpty() ) {
- log.warn("OAuth2 enabled but no provider is enabled!");
- throw new IllegalStateException("OAuth2 enabled but no provider is enabled!");
- }
-
- return new InMemoryClientRegistrationRepository(activeList);
- }
-
- /** 从 state 中提取 bindNonce,格式:<原始state>,bindnonce: */
+ /** 从 state 中提取 bindNonce,格式:{@code 原始state,bindnonce:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} */
private String extractBindNonce(String state) {
if ( state == null ) return null;
for ( String part : state.split(",") ) {
diff --git a/src/main/java/org/miowing/mioverify/util/Util.java b/src/main/java/org/miowing/mioverify/util/Util.java
index b77cf46..8628aea 100644
--- a/src/main/java/org/miowing/mioverify/util/Util.java
+++ b/src/main/java/org/miowing/mioverify/util/Util.java
@@ -53,7 +53,6 @@ public String getTextureURL(String hash) {
return getServerURL() + "/texture/hash/" + hash;
}
public String getServerURL() {
- // TODO
return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
}
public String signature(String value) {
diff --git a/src/test/resources/application-test-sqlite.yml b/src/test/resources/application-test-sqlite.yml
index c82c25d..e69de29 100644
--- a/src/test/resources/application-test-sqlite.yml
+++ b/src/test/resources/application-test-sqlite.yml
@@ -1,4 +0,0 @@
-spring:
- security:
- oauth2:
- enabled: false # 禁用 OAuth2
\ No newline at end of file
From 52a584703be3387d90f969746b185fe1e1780d63 Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Sun, 15 Mar 2026 17:45:06 +0800
Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=E5=A4=9A=E5=A4=84=E9=97=AE?=
=?UTF-8?q?=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 5 ++
pom.xml | 2 +-
.../mioverify/config/SecurityConfig.java | 2 +-
.../mioverify/controller/OAuthController.java | 25 +++++--
.../mioverify/pojo/oauth/OAuthAuthReq.java | 2 +-
.../service/impl/OAuthServiceImpl.java | 70 ++++++++++++++++---
src/main/resources/application.yml | 4 +-
7 files changed, 89 insertions(+), 21 deletions(-)
diff --git a/.gitignore b/.gitignore
index 549e00a..3855bf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,8 @@ build/
### VS Code ###
.vscode/
+
+### TEST ###
+data.db
+logs
+keys
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 68e1bf4..ec361f3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.miowing
MioVerify
- commit-9b9a04258456b6223be2d7fc998bd58c03c08875
+ commit-0bcfa8221c049a599ba29e1eba10ed17108b39ad
MioVerify
A Minecraft verification server implementing Yggdrasil API
diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
index aba9daf..1cbb8d7 100644
--- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -150,7 +150,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepo
.csrf(AbstractHttpConfigurer :: disable)
.sessionManagement(session -> session
- .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.authorizeHttpRequests(auth -> auth
diff --git a/src/main/java/org/miowing/mioverify/controller/OAuthController.java b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
index 445cd1f..f641045 100644
--- a/src/main/java/org/miowing/mioverify/controller/OAuthController.java
+++ b/src/main/java/org/miowing/mioverify/controller/OAuthController.java
@@ -1,6 +1,5 @@
package org.miowing.mioverify.controller;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.miowing.mioverify.dao.UserDao;
@@ -27,6 +26,8 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+import org.springframework.web.util.UriComponentsBuilder;
import java.util.ArrayList;
import java.util.List;
@@ -117,7 +118,7 @@ public AuthResp authenticate(@RequestBody OAuthAuthReq req) {
.setClientToken(cToken)
.setAvailableProfiles(profiles)
.setSelectedProfile(bindProfile)
- .setUser(req.getRequestUser() ? util.userToShow(user) : null);
+ .setUser(req.isRequestUser() ? util.userToShow(user) : null);
}
/**
@@ -189,10 +190,14 @@ public ResponseEntity> bindProvider(
String nonce = Util.genUUID();
redisService.saveOAuthBindNonce(nonce, user.getId());
- String serverUrl = (dataUtil.isUseHttps() ? "https://" : "http://")
- + dataUtil.getServerDomain() + ":" + dataUtil.getPort();
- String authUrl = serverUrl + "/oauth/authorize/" + provider
- + "?bind_nonce=" + nonce;
+ String serverUrl = ServletUriComponentsBuilder
+ .fromCurrentContextPath()
+ .toUriString();
+
+ String authUrl = UriComponentsBuilder.fromHttpUrl(serverUrl)
+ .pathSegment("oauth", "authorize", provider)
+ .queryParam("bind_nonce", nonce)
+ .toUriString();
log.info("User {} initiate bind for provider: {}", user.getId(), provider);
@@ -256,7 +261,13 @@ public ResponseEntity> signOut(@RequestHeader("Authorization") String authoriz
throw new UnauthorizedException(); // 401
}
- User user = userDao.selectOne(new LambdaQueryWrapper().eq(User :: getUsername, aToken.name()));
+ // 通过绑定的 Profile ID 获取用户 ID,避免用户名重复
+ Profile profile = profileService.getById(aToken.bindProfile());
+ if ( profile == null ) {
+ throw new UnauthorizedException(); // 401
+ }
+
+ User user = userDao.selectById(profile.getBindUser());
if ( user == null ) {
throw new UnauthorizedException(); // 401
diff --git a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java
index 9ba9467..9c46b32 100644
--- a/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java
+++ b/src/main/java/org/miowing/mioverify/pojo/oauth/OAuthAuthReq.java
@@ -17,6 +17,6 @@ public class OAuthAuthReq {
private @Nullable String clientToken;
/** 是否在响应中返回用户信息 */
- private Boolean requestUser;
+ private boolean requestUser;
}
diff --git a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
index 562dd93..b163472 100644
--- a/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
+++ b/src/main/java/org/miowing/mioverify/service/impl/OAuthServiceImpl.java
@@ -67,7 +67,7 @@ public OAuthCallbackResp handleOAuthLogin(
BiConsumer setter = PROVIDER_SETTER.get(provider);
String fieldName = PROVIDER_FIELD.get(provider);
if ( setter == null || fieldName == null ) {
- throw new FeatureNotSupportedException(); //不支持的 OAuth 提供商
+ throw new FeatureNotSupportedException(); // 不支持的 OAuth 提供商
}
if ( providerUserId.isBlank() ) {
throw new IllegalArgumentException(); // 提供商用户 ID 不能为空
@@ -77,10 +77,14 @@ public OAuthCallbackResp handleOAuthLogin(
new QueryWrapper().eq(fieldName, providerUserId)
);
- if ( user == null ) {
+ boolean isNewUser = (user == null);
+ if ( isNewUser ) {
user = createUser(provider, providerUserId, providerUsername, setter);
}
+ boolean hasProfile = ! profileService.getByUserId(user.getId()).isEmpty();
+ boolean needProfile = isNewUser || ! hasProfile;
+
// 生成临时令牌并存入 Redis
String tempToken = Util.genUUID();
redisService.saveOAuthTempToken(tempToken, user.getId());
@@ -89,18 +93,32 @@ public OAuthCallbackResp handleOAuthLogin(
.setTempToken(tempToken)
.setProvider(provider)
.setProviderUsername(providerUsername)
- .setNeedProfile(false); //暂时设置为 false
+ .setNeedProfile(needProfile)
+ .setUserId(needProfile ? user.getId() : null); // 需要创建 Profile 时返回 userId
}
@Override
public void unbind(@NonNull String userId, @NonNull String provider) {
BiConsumer setter = PROVIDER_SETTER.get(provider);
if ( setter == null ) {
- throw new FeatureNotSupportedException();//不支持的 OAuth 提供商
+ throw new FeatureNotSupportedException(); // 不支持的 OAuth 提供商
}
User user = userDao.selectById(userId);
+ if ( user == null ) {
+ throw new FeatureNotSupportedException(); // 用户不存在
+ }
+
+ // 检查是否为唯一认证方式(有密码或有其他绑定的 Provider)
+ boolean hasPassword = user.getPassword() != null && ! user.getPassword().isBlank();
+ int boundProviderCount = countBoundProviders(user);
+
+ // 如果没有密码且只有一个绑定的 Provider,则不允许解绑
+ if ( ! hasPassword && boundProviderCount <= 1 ) {
+ throw new FeatureNotSupportedException(); // 无法解绑唯一的认证方式
+ }
+
// 清空对应的 provider ID
setter.accept(user, null);
userDao.updateById(user);
@@ -139,25 +157,59 @@ public void handleOAuthBind(String userId, String provider, String providerUserI
//-----------
- /**
- * 创建新用户
- */
+ /** 创建新用户 */
private User createUser(String provider, String providerUserId, String providerUsername,
BiConsumer setter) {
User newUser = new User();
newUser.setId(Util.genUUID()); // 使用工具类生成 ID
+
// 用户名处理:优先使用提供商返回的用户名,否则生成默认名
- String username = (providerUsername != null && ! providerUsername.isBlank())
+ String baseUsername = (providerUsername != null && ! providerUsername.isBlank())
? providerUsername
: "user_" + providerUserId.substring(0, Math.min(8, providerUserId.length()));
+
+ // 检查用户名是否已存在,如存在则添加随机后缀
+ String username = ensureUniqueUsername(baseUsername);
+
newUser.setUsername(username);
+
// 设置对应的 provider ID
setter.accept(newUser, providerUserId);
- log.info("New user register: {}", newUser.getUsername());
+ log.info("New Oauth user register: {}", newUser.getUsername());
userService.save(newUser);
return newUser;
}
+ /** 统计用户已绑定的 Provider 数量 */
+ private int countBoundProviders(User user) {
+ int count = 0;
+ if ( user.getGithubId() != null && ! user.getGithubId().isBlank() ) count++;
+ if ( user.getMicrosoftId() != null && ! user.getMicrosoftId().isBlank() ) count++;
+ if ( user.getMcjpgId() != null && ! user.getMcjpgId().isBlank() ) count++;
+ if ( user.getCustomId() != null && ! user.getCustomId().isBlank() ) count++;
+ return count;
+ }
+
+ /** 确保用户名唯一,如果已存在则添加随机后缀 */
+ private String ensureUniqueUsername(String baseUsername) {
+ String username = baseUsername;
+ int maxAttempts = 10;
+
+ for ( int i = 0; i < maxAttempts; i++ ) {
+ User existing = userDao.selectOne(
+ new QueryWrapper().eq("username", username)
+ );
+ if ( existing == null ) {
+ return username; // 用户名可用
+ }
+ // 用户名已存在,添加随机后缀
+ username = baseUsername + "_" + Util.genUUID().substring(0, 6);
+ }
+
+ // 极端情况:多次尝试后仍冲突,使用 UUID
+ return baseUsername + "_" + Util.genUUID();
+ }
+
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index bce325d..ed15a7c 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -17,8 +17,8 @@ server:
key-alias: boot
servlet:
application-display-name: MioVerify Alpha
- # 自动适配 Nginx 和 Cloudflare
- forward-headers-strategy: framework
+ # 自动适配 Nginx 和 Cloudflare,如果你使用了这两个东西,请设为 native 或 framework(更建议) 。而不是 none
+ forward-headers-strategy: none
spring:
From 90ff2aebea084503eb643348ed949dc9f897b602 Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Sun, 15 Mar 2026 23:00:23 +0800
Subject: [PATCH 09/12] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E9=83=A8?=
=?UTF-8?q?=E7=BD=B2=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application.yml | 2 +-
...64\346\227\266\346\226\207\344\273\266.md" | 248 ++++++++++++++++++
2 files changed, 249 insertions(+), 1 deletion(-)
create mode 100644 "\344\270\264\346\227\266\346\226\207\344\273\266.md"
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index ed15a7c..7adc960 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -99,7 +99,7 @@ mybatis-plus:
mioverify:
- # OAuth 前端回调地址 (全部uri均可修改)
+ # OAuth 前端回调地址 (路径不可修改)
oauth-frontend-redirect-uri: "https://your-frontend.com/oauth/callback"
# 跨域配置,生产环境中必须设置
diff --git "a/\344\270\264\346\227\266\346\226\207\344\273\266.md" "b/\344\270\264\346\227\266\346\226\207\344\273\266.md"
new file mode 100644
index 0000000..f5cc24b
--- /dev/null
+++ "b/\344\270\264\346\227\266\346\226\207\344\273\266.md"
@@ -0,0 +1,248 @@
+# MioVerify 部署配置检查清单
+
+> 本文档面向首次部署 MioVerify 的用户。请按顺序逐项检查,带 🔴 的为 **必须修改** 项,带 🟡 的为 **按需修改** 项。
+
+---
+
+## 一、数据库选择
+
+| 配置项 | 位置 | 说明 |
+|-----------------------------|-----|----------------------------------------------------|
+| 🔴 `spring.profiles.active` | 主配置 | 选择数据库类型:`test-sqlite`(轻量/单机)或 `test-mysql`(推荐生产环境) |
+
+### 如果选择 MySQL
+
+在配置文件底部 `test-mysql` 段修改:
+
+| 配置项 | 要改成什么 | 示例 |
+|---------------|------------|-----------------------------------------|
+| 🔴 `url` | 你的数据库地址和库名 | `jdbc:mysql://localhost:3306/mioverify` |
+| 🔴 `username` | 数据库用户名 | `mioverify` |
+| 🔴 `password` | 数据库密码 | 一个强密码 |
+
+> 💡 使用前需先手动创建好数据库(`CREATE DATABASE mioverify;`)
+
+### 如果选择 SQLite
+
+无需额外配置,数据会自动保存在项目目录的 `data.db` 文件中。
+
+---
+
+## 二、Redis 配置
+
+| 配置项 | 位置 | 要改成什么 |
+|-----------------------------|-----|-----------------------------|
+| 🟡 `spring.data.redis.host` | 主配置 | Redis 服务器地址(默认 `localhost`) |
+| 🟡 `spring.data.redis.port` | 主配置 | Redis 端口(默认 `6379`) |
+
+> ⚠️ Redis 是 **必须运行** 的依赖。部署前请确保 Redis 已安装并启动。
+>
+> 如果 Redis 有密码或在远程服务器,改用 URL 模式:
+> ```yaml
+> spring.data.redis.url: redis://:你的密码@地址:端口
+> ```
+
+---
+
+## 三、安全相关
+
+| 配置项 | 默认值 | 要做什么 |
+|---------------------------------------------------|------------------|-----------------|
+| 🔴 `mioverify.token.signature` | `abcd273nsi179a` | 替换为一段 **随机字符串** |
+| 🔴 `mioverify.extern.register.permission-key.key` | `admin123098` | 替换为你自己的管理密钥 |
+
+> 💡 生成随机字符串的方法(Linux/Mac 终端):
+> ```bash
+> openssl rand -hex 32
+> ```
+
+---
+
+## 四、域名与网络
+
+### 4.1 服务器域名(必须修改)
+
+| 配置项 | 默认值 | 要改成什么 |
+|------------------------------------|-------------|-----------------------------|
+| 🔴 `mioverify.props.server-domain` | `localhost` | 你的实际域名,如 `auth.example.com` |
+| 🔴 `mioverify.props.use-https` | `false` | 如果使用 HTTPS 则改为 `true` |
+| 🔴 `mioverify.props.skin-domains` | `baidu.com` | 改为你的域名,如 `example.com` |
+
+> 💡 `skin-domains` 是 MC 客户端加载皮肤时的域名白名单,必须包含你的域名,否则皮肤不显示。
+
+### 4.2 端口
+
+| 配置项 | 默认值 | 说明 |
+|------------------|--------|--------------|
+| 🟡 `server.port` | `8080` | 服务监听端口,可按需修改 |
+
+### 4.3 反向代理( Nginx / Cloudflare )
+
+| 配置项 | 默认值 | 什么时候改 |
+|--------------------------------------|--------|---------------------------------------------|
+| 🟡 `server.forward-headers-strategy` | `none` | 使用了 Nginx 或 Cloudflare **必须**改为 `framework` |
+
+> ⚠️ 如果你用了反向代理但没改这个值,会导致:
+> - OAuth 回调地址错误
+> - 获取到的用户 IP 是代理服务器的 IP
+> - HTTPS 判断失败
+
+---
+
+## 五、SSL / HTTPS
+
+### 方案 A:用 Nginx 反代处理 SSL(✅ 推荐)
+
+保持以下配置不变即可:
+
+```yaml
+server.ssl.enabled: false
+server.forward-headers-strategy: framework # 记得改这个!
+mioverify.props.use-https: true # 改为 true
+```
+
+> Nginx 负责 SSL 终止,后端只跑 HTTP。
+
+### 方案 B:让 MioVerify 自己处理 SSL
+
+| 配置项 | 要改成什么 |
+|------------------------------------|---------------------------|
+| 🟡 `server.ssl.enabled` | `true` |
+| 🟡 `server.ssl.http-port` | HTTP 入口端口(会自动跳转 HTTPS) |
+| 🟡 `server.ssl.key-store` | 你的证书文件路径 |
+| 🟡 `server.ssl.key-store-password` | 证书密码(**不要用默认的 `123456`**) |
+| 🟡 `server.ssl.key-store-type` | `jks` 或 `PKCS12` |
+| 🟡 `server.ssl.key-alias` | 证书别名 |
+
+---
+
+## 六、OAuth2 第三方登录
+
+> 如果你不需要某个登录方式,把对应的 `enabled` 设为 `false` 即可跳过。
+
+### 6.1 GitHub
+
+| 配置项 | 要做什么 |
+|--------------------|---------------------------------------------------------------------------------------|
+| 🟡 `enabled` | 是否启用 |
+| 🔴 `client-id` | 去 [GitHub Developer Settings](https://github.com/settings/developers) 创建 OAuth App 获取 |
+| 🔴 `client-secret` | 同上 |
+
+> 创建 GitHub OAuth App 时,回调地址填:`https://你的域名/oauth/callback/github`
+
+### 6.2 Microsoft
+
+| 配置项 | 要做什么 |
+|--------------------|---------------------------------------------------------------------------------------|
+| 🟡 `enabled` | 是否启用 |
+| 🔴 `client-id` | 去 [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) 注册应用获取 |
+| 🔴 `client-secret` | 同上 |
+
+> 回调地址填:`https://你的域名/oauth/callback/microsoft`
+
+### 6.3 mcjpg
+
+| 配置项 | 要做什么 |
+|--------------------|----------------------------------------------|
+| 🟡 `enabled` | 是否启用 |
+| 🔴 `client-id` | 在 [mcjpg SSO](https://sso.mcjpg.org/) 平台注册获取 |
+| 🔴 `client-secret` | 同上 |
+
+### 6.4 自定义 Provider
+
+| 配置项 | 要做什么 |
+|----------------------------------------|----------------|
+| 🟡 `enabled` | 不使用请设为 `false` |
+| 🔴 `client-id` / `client-secret` | 对应平台获取 |
+| 🔴 `provider.custom.authorization-uri` | 授权端点 URL |
+| 🔴 `provider.custom.token-uri` | Token 端点 URL |
+| 🔴 `provider.custom.user-info-uri` | 用户信息端点 URL |
+| 🟡 `client-name` | 显示名称 |
+
+### 6.5 OAuth 前端回调
+
+| 配置项 | 默认值 | 要改成什么 |
+|--------------------------------------------|--------------------------------------------|-------------|
+| 🔴 `mioverify.oauth-frontend-redirect-uri` | `https://your-frontend.com/oauth/callback` | 你的前端实际回调页地址 |
+
+---
+
+## 七、跨域配置(CORS)
+
+> 仅在 **前后端域名不同** 时需要配置。注意 `api.example.com` 和 `login.example.com` 不是同一个域名。
+
+| 配置项 | 什么时候改 | 改成什么 |
+|------------------------------------|----------|-----------------------------------|
+| 🟡 `mioverify.cors.enabled` | 前后端不同域名时 | `true` |
+| 🟡 `mioverify.cors.allowed-origin` | 同上 | 前端域名,如 `https://skin.example.com` |
+
+> ⚠️ 生产环境 **不要** 设为 `*`,会有安全风险。
+
+---
+
+## 八、注册 API
+
+| 配置项 | 默认值 | 说明 |
+|---------------------------------------------|---------|-----------------------------|
+| 🟡 `extern.register.enabled` | `false` | 是否开放注册(不开则只能后台添加用户) |
+| 🟡 `extern.register.allow-user` | `true` | 允许注册账号 |
+| 🟡 `extern.register.allow-profile` | `false` | 允许注册游戏角色 |
+| 🟡 `extern.register.profile-strict` | `true` | 注册角色时要求验证密码(建议保持 `true`) |
+| 🟡 `extern.register.permission-key.enabled` | `false` | 是否需要密钥才能注册(**开放注册时强烈建议开启**) |
+| 🟡 `mioverify.extern.multi-profile-name` | `true` | 是否允许不同用户取相同角色名 |
+
+---
+
+## 九、Token 与 Session
+
+| 配置项 | 默认值 | 说明 |
+|------------------------------------|-------|-----------------|
+| 🟡 `mioverify.token.expire` | `10m` | Token 暂时失效时间 |
+| 🟡 `mioverify.token.invalid` | `1h` | Token 过期时间 |
+| 🟡 `mioverify.session.expire` | `6m` | Session 过期时间 |
+| 🟡 `spring.security.oauth2.expire` | `5m` | OAuth State 有效期 |
+
+> 💡 部署时不建议保持默认。
+> Minecraft 原版暂时失效为 24 小时
+
+---
+
+## 十、材质( 皮肤 / 披风 )
+
+| 配置项 | 默认值 | 说明 |
+|---------------------------------------------|-----------------------------|------------|
+| 🟡 `mioverify.texture.storage-loc` | `textures` | 材质文件存储目录 |
+| 🟡 `mioverify.texture.default-skin-loc` | `textures/skin/default.png` | 默认皮肤图片路径 |
+| 🟡 `spring.servlet.multipart.max-file-size` | `800KB` | 上传皮肤最大文件大小 |
+
+> 💡 需要提前准备一张默认皮肤 PNG 文件放到对应路径。
+
+---
+
+## 十一、服务器元数据
+
+| 配置项 | 默认值 | 要改成什么 |
+|---------------------------------------------|-------------------------|------------------|
+| 🟡 `props.meta.server-name` | `MioVerify 验证服务器` | 你的服务器名称(显示在启动器中) |
+| 🟡 `props.meta.links.home-page` | `https://www.baidu.com` | 你的主页地址 |
+| 🟡 `props.meta.links.register` | `https://www.baidu.com` | 你的注册页地址 |
+| 🟡 `mioverify.security.profile-batch-limit` | `5` | 批量查询角色上限 |
+
+---
+
+## 部署前最终自检
+
+请确认以下所有项都已完成:
+
+- [ ] 选好了数据库并填写了连接信息
+- [ ] Redis 已安装并正常运行
+- [ ] `token.signature` 已替换为随机字符串
+- [ ] `permission-key.key` 已替换
+- [ ] `server-domain` 已改为实际域名
+- [ ] `skin-domains` 已改为实际域名
+- [ ] `use-https` 根据实际情况设置
+- [ ] 使用反向代理时 `forward-headers-strategy` 已改为 `framework`
+- [ ] OAuth2 的 `client-id` 和 `client-secret` 已填入真实值(或关闭不用的)
+- [ ] `oauth-frontend-redirect-uri` 已改为前端实际地址
+- [ ] 默认皮肤文件已放置到位
+- [ ] SSL 证书密码不是默认的 `123456`(如果由应用处理 SSL)
\ No newline at end of file
From b7a922f3085b51accc4133a9e9618c150262d0c7 Mon Sep 17 00:00:00 2001
From: pingguo <141195321+pingguomc@users.noreply.github.com>
Date: Mon, 16 Mar 2026 23:18:28 +0800
Subject: [PATCH 10/12] Potential fix for code scanning alert no. 3: Disabled
Spring CSRF protection
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
src/main/java/org/miowing/mioverify/config/SecurityConfig.java | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
index 1cbb8d7..d2c15d4 100644
--- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -147,8 +147,6 @@ public ClientRegistrationRepository clientRegistrationRepository() {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception {
http
- .csrf(AbstractHttpConfigurer :: disable)
-
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
From 02f6fcddbf0c6058b114724ae578c3f78a02c067 Mon Sep 17 00:00:00 2001
From: pingguomc <141195321+pingguomc@users.noreply.github.com>
Date: Mon, 16 Mar 2026 23:28:44 +0800
Subject: [PATCH 11/12] =?UTF-8?q?fix:=20CSRF=20=E9=94=99=E8=AF=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../mioverify/config/SecurityConfig.java | 21 ++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
index d2c15d4..6df2e70 100644
--- a/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
+++ b/src/main/java/org/miowing/mioverify/config/SecurityConfig.java
@@ -13,7 +13,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
-import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
@@ -24,6 +23,8 @@
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.util.UriComponentsBuilder;
@@ -132,7 +133,7 @@ public ClientRegistrationRepository clientRegistrationRepository() {
*
* 过滤链处理顺序:
*
- * - 禁用 CSRF
+ * - 设置 CSRF
* - 设置无状态 Session(不创建 HttpSession)
* - 放行所有请求
* - 若 OAuth 总开关开启,注册 OAuth2 登录流程
@@ -147,6 +148,20 @@ public ClientRegistrationRepository clientRegistrationRepository() {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception {
http
+ .csrf(csrf -> csrf
+ .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
+ .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
+ .ignoringRequestMatchers(
+ "/authserver/**",
+ "/sessionserver/**",
+ "/minecraftservices/**",
+ "/oauth/callback/*",
+ "/api/**",
+ "/extern/**",
+ "/texture/**"
+ )
+ )
+
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
@@ -343,7 +358,7 @@ private String extractBindNonce(String state) {
/** 工具方法:重定向并带一个查询参数 */
private void redirect(
jakarta.servlet.http.HttpServletResponse response,
- String baseUrl, String key, String value) throws java.io.IOException {
+ String baseUrl, String key, String value) {
String url = UriComponentsBuilder.fromHttpUrl(baseUrl)
.queryParam(key, value)
From cbb34232ebcb75972d3e771902f3ba782a376d48 Mon Sep 17 00:00:00 2001
From: pingguo <141195321+pingguomc@users.noreply.github.com>
Date: Mon, 16 Mar 2026 23:30:37 +0800
Subject: [PATCH 12/12] Update version to 1.4.0 in pom.xml
---
pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pom.xml b/pom.xml
index ec361f3..94b1498 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.miowing
MioVerify
- commit-0bcfa8221c049a599ba29e1eba10ed17108b39ad
+ 1.4.0
MioVerify
A Minecraft verification server implementing Yggdrasil API