diff --git a/build.gradle b/build.gradle index cea70017..fab3c072 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/src/main/java/plus/maa/backend/common/utils/WebUtils.java b/src/main/java/plus/maa/backend/common/utils/WebUtils.java index d66bc5a4..d9f82d35 100644 --- a/src/main/java/plus/maa/backend/common/utils/WebUtils.java +++ b/src/main/java/plus/maa/backend/common/utils/WebUtils.java @@ -1,12 +1,14 @@ package plus.maa.backend.common.utils; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; /** * @author AnselYuki */ +@Slf4j public class WebUtils { public static void renderString(HttpServletResponse response, String json, int code) { try { @@ -15,7 +17,7 @@ public static void renderString(HttpServletResponse response, String json, int c response.setCharacterEncoding("UTF-8"); response.getWriter().println(json); } catch (IOException e) { - e.printStackTrace(); + log.error(e.getMessage(), e); } } } diff --git a/src/main/java/plus/maa/backend/config/security/OIDCAuthenticationSuccessHandler.java b/src/main/java/plus/maa/backend/config/security/OIDCAuthenticationSuccessHandler.java new file mode 100644 index 00000000..a93bafd3 --- /dev/null +++ b/src/main/java/plus/maa/backend/config/security/OIDCAuthenticationSuccessHandler.java @@ -0,0 +1,93 @@ +package plus.maa.backend.config.security; + +import cn.hutool.core.lang.Assert; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import plus.maa.backend.common.utils.WebUtils; +import plus.maa.backend.controller.response.MaaResult; +import plus.maa.backend.controller.response.user.MaaLoginRsp; +import plus.maa.backend.repository.UserRepository; +import plus.maa.backend.repository.entity.MaaUser; +import plus.maa.backend.service.UserService; + +import java.io.IOException; + +/** + * 适配 Maa Account + * + * @author lixuhuilll + * Date 2023/9/22 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OIDCAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + private final UserService userService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + try { + authenticationSuccess(request, response, authentication); + } catch (AuthenticationException e) { + throw e; + } catch (RuntimeException e) { + // 将运行时异常转换为 AuthenticationException 的子类型,触发统一的异常响应 + throw new OIDCAuthenticationException(e.getMessage()); + } finally { + // 删除在身份验证过程中可能已存储在会话中的临时身份验证相关数据 + clearAuthenticationAttributes(request); + } + } + + public void authenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + if (!(authentication instanceof OAuth2AuthenticationToken oauth2Token)) { + throw new OIDCAuthenticationException("无法取得授权信息"); + } + + OAuth2User oAuth2User = oauth2Token.getPrincipal(); + + String email = oAuth2User.getAttribute("email"); + Assert.notBlank(email, "无法取得邮箱"); + + MaaUser maaUser = userRepository.findByEmail(email); + if (maaUser == null) { + // 如果不存在绑定好的邮箱,则注册新用户 + String userName = oAuth2User.getAttribute("preferred_username"); + Assert.notBlank(userName, "无法取得用户名"); + + maaUser = new MaaUser() + .setUserName(userName) + .setEmail(email) + .setStatus(1); + maaUser = userRepository.save(maaUser); + } else if (maaUser.getStatus() == null || maaUser.getStatus() == 0) { + // 存在对应邮箱的用户但未激活时,自动激活 + maaUser.setStatus(1); + userRepository.save(maaUser); + } + + // 响应登录数据 + MaaResult result = MaaResult.success("登陆成功", userService.maaLoginRsp(maaUser)); + String json = objectMapper.writeValueAsString(result); + WebUtils.renderString(response, json, 200); + } + + static class OIDCAuthenticationException extends AuthenticationException { + public OIDCAuthenticationException(String msg) { + super(msg); + } + } +} diff --git a/src/main/java/plus/maa/backend/config/security/OIDCRedirectStrategy.java b/src/main/java/plus/maa/backend/config/security/OIDCRedirectStrategy.java new file mode 100644 index 00000000..8152dfd8 --- /dev/null +++ b/src/main/java/plus/maa/backend/config/security/OIDCRedirectStrategy.java @@ -0,0 +1,36 @@ +package plus.maa.backend.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.core.log.LogMessage; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.stereotype.Component; +import plus.maa.backend.common.utils.WebUtils; +import plus.maa.backend.controller.response.MaaResult; +import plus.maa.backend.controller.response.user.OIDCInfo; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OIDCRedirectStrategy extends DefaultRedirectStrategy { + + private final ObjectMapper objectMapper; + + @Override + public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + String redirectUrl = calculateRedirectUrl(request.getContextPath(), url); + redirectUrl = response.encodeRedirectURL(redirectUrl); + if (this.logger.isDebugEnabled()) { + this.logger.debug(LogMessage.format("Redirecting to %s", redirectUrl)); + } + // 不再重定向,而是响应流水号和目标地址 + String serial = (String) request.getAttribute(RedisOAuth2AuthorizationRequestRepository.getREQUEST_KEY()); + OIDCInfo oidcInfo = new OIDCInfo(serial, redirectUrl); + MaaResult result = MaaResult.success(oidcInfo); + String json = objectMapper.writeValueAsString(result); + WebUtils.renderString(response, json, 200); + } +} diff --git a/src/main/java/plus/maa/backend/config/security/RedisOAuth2AuthorizationRequestRepository.java b/src/main/java/plus/maa/backend/config/security/RedisOAuth2AuthorizationRequestRepository.java new file mode 100644 index 00000000..f5a8e030 --- /dev/null +++ b/src/main/java/plus/maa/backend/config/security/RedisOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,93 @@ +package plus.maa.backend.config.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import plus.maa.backend.repository.RedisCache; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 使用本储存库储存 OAuth2AuthorizationRequest 时,前端的回调请求必须携带流水号 + * 流水号必须在 HTTP_HEAD_NAME 所指示的 Http Head 中,否则将提示 [authorization_request_not_found] + * + * @author lixuhuilll + * Date 2023/9/22 + */ + +@Component +@RequiredArgsConstructor +public class RedisOAuth2AuthorizationRequestRepository + implements AuthorizationRequestRepository { + + private static final String REDIS_KEY_PREFIX = "oidc:serial:"; + @Getter + private static final String REQUEST_KEY = "oidc_serial"; + private static final String HTTP_HEAD_NAME = "OIDC-Serial"; + // 默认缓存 20 分钟,超过 20 分钟后,授权必然失败,用户需要在 20 分钟内从 Maa Account 回调回来 + private static final int TIMEOUT = 60 * 20; + + private final RedisCache redisCache; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + Assert.notNull(request, "request cannot be null"); + String stateParameter = getStateParameter(request); + if (stateParameter == null) { + return null; + } + OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest(request); + return (authorizationRequest != null && stateParameter.equals(authorizationRequest.getState())) + ? authorizationRequest : null; + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + if (authorizationRequest == null) { + removeAuthorizationRequest(request, response); + return; + } + String state = authorizationRequest.getState(); + Assert.hasText(state, "authorizationRequest.state cannot be empty"); + // 不再使用 Session + String serial = UUID.randomUUID().toString(); + request.setAttribute(REQUEST_KEY, serial); + redisCache.setCache(REDIS_KEY_PREFIX + serial, authorizationRequest, TIMEOUT, TimeUnit.SECONDS); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { + Assert.notNull(response, "response cannot be null"); + OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request); + if (authorizationRequest != null) { + redisCache.removeCache(REDIS_KEY_PREFIX + getSerial(request)); + } + return authorizationRequest; + } + + private String getStateParameter(HttpServletRequest request) { + return request.getParameter(OAuth2ParameterNames.STATE); + } + + private String getSerial(HttpServletRequest request) { + String serial = (String) request.getAttribute(REQUEST_KEY); + if (serial == null) { + serial = request.getHeader(HTTP_HEAD_NAME); + } + return serial; + } + + private OAuth2AuthorizationRequest getAuthorizationRequest(HttpServletRequest request) { + String serial = getSerial(request); + return redisCache.getCache(REDIS_KEY_PREFIX + serial, OAuth2AuthorizationRequest.class); + } +} diff --git a/src/main/java/plus/maa/backend/config/security/SecurityConfig.java b/src/main/java/plus/maa/backend/config/security/SecurityConfig.java index 0922176b..478b5628 100644 --- a/src/main/java/plus/maa/backend/config/security/SecurityConfig.java +++ b/src/main/java/plus/maa/backend/config/security/SecurityConfig.java @@ -1,22 +1,32 @@ package plus.maa.backend.config.security; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.DelegatingPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import java.util.HashMap; + import static org.springframework.security.config.Customizer.withDefaults; /** * @author AnselYuki */ +@Slf4j @Configuration @RequiredArgsConstructor public class SecurityConfig { @@ -26,7 +36,9 @@ public class SecurityConfig { private static final String[] URL_WHITELIST = { "/user/login", "/user/register", - "/user/sendRegistrationToken" + "/user/sendRegistrationToken", + "/oidc/authorization/maa-account", + "/oidc/callback/maa-account" }; private static final String[] URL_PERMIT_ALL = { @@ -70,10 +82,28 @@ public class SecurityConfig { private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; private final AuthenticationEntryPointImpl authenticationEntryPoint; private final AccessDeniedHandlerImpl accessDeniedHandler; + private final OIDCAuthenticationSuccessHandler oidcAuthenticationSuccessHandler; + private final OIDCRedirectStrategy oidcRedirectStrategy; + private final RedisOAuth2AuthorizationRequestRepository redisOAuth2AuthorizationRequestRepository; - @Bean - public BCryptPasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + @Configuration(proxyBeanMethods = false) + public static class PasswordEncoderCreate { + + private static final String BCRYPT = "bcrypt"; + + @Bean + public PasswordEncoder passwordEncoder() { + // 被用于委托的密码编码器,其中一个会用于密码编码,其他的则用于密码匹配 + var encoders = new HashMap(); + encoders.put(BCRYPT, new BCryptPasswordEncoder()); + + // 创建委托密码编码器,指定用于密码编码的编码器 id,以及被委托的编码器映射 + var delegating = new DelegatingPasswordEncoder(BCRYPT, encoders); + + // 兼容旧数据中直接裸使用 BCrypt 编码器的密码 + delegating.setDefaultPasswordEncoderForMatches(encoders.get(BCRYPT)); + return delegating; + } } @Bean @@ -98,14 +128,55 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //此处用于管理员操作接口 .requestMatchers(URL_AUTHENTICATION_2).hasAuthority("2") .anyRequest().authenticated()); + + + // 存在 Maa Account 配置时,才启用 OIDC + Customizer> oauth2LoginCustomizer = null; + + try { + // 依赖于 CGLIB 子类处理,proxyBeanMethods 需要为 true + oauth2LoginCustomizer = oauth2LoginCustomizer(); + } catch (NoSuchBeanDefinitionException e) { + log.info("Maa Account 配置不存在,已关闭 OIDC 认证"); + } + + if (oauth2LoginCustomizer != null) { + http.oauth2Login(oauth2LoginCustomizer); + } + //添加过滤器 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); //配置异常处理器,处理认证失败的JSON响应 http.exceptionHandling(exceptionHandling -> exceptionHandling.authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler)); - //开启跨域请求 http.cors(withDefaults()); return http.build(); } + + @Bean + @ConditionalOnProperty("spring.security.oauth2.client.provider.maa-account.issuer-uri") + public Customizer> oauth2LoginCustomizer() { + return login -> { + // 以下的链接默认值以配置文件中使用 maa-account 作为 OIDC 服务器时为例 + // Get 请求访问 "/oidc/authorization/maa-account" 将自动配置参数并跳转到 OIDC 认证页面 + login.authorizationEndpoint( + endpoint -> { + endpoint.baseUri("/oidc/authorization"); + // 请求 OIDC 认证时不再自动重定向 + endpoint.authorizationRedirectStrategy(oidcRedirectStrategy); + // 不再使用 Session 储存信息 + endpoint.authorizationRequestRepository(redisOAuth2AuthorizationRequestRepository); + } + ); + // 回调接口,默认为 "/oidc/callback/maa-account" + login.redirectionEndpoint( + redirection -> redirection.baseUri("/oidc/callback/*") + ); + // 登录异常处理器 + login.failureHandler(authenticationEntryPoint::commence); + // 登录成功处理器 + login.successHandler(oidcAuthenticationSuccessHandler); + }; + } } diff --git a/src/main/java/plus/maa/backend/controller/response/user/OIDCInfo.java b/src/main/java/plus/maa/backend/controller/response/user/OIDCInfo.java new file mode 100644 index 00000000..1d3e02e3 --- /dev/null +++ b/src/main/java/plus/maa/backend/controller/response/user/OIDCInfo.java @@ -0,0 +1,24 @@ +package plus.maa.backend.controller.response.user; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +/** + * OIDC 登录认证时,响应前端流水号以及重定向 URL + * + * @author lixuhuilll + * Date 2023/9/22 + */ + +@Data +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +public class OIDCInfo { + // 流水号 + private String serial; + // 重定向 URL + private String redirectUrl; +} diff --git a/src/main/java/plus/maa/backend/repository/RedisCache.java b/src/main/java/plus/maa/backend/repository/RedisCache.java index 88ea5afa..a821cf4a 100644 --- a/src/main/java/plus/maa/backend/repository/RedisCache.java +++ b/src/main/java/plus/maa/backend/repository/RedisCache.java @@ -1,9 +1,12 @@ package plus.maa.backend.repository; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -18,6 +21,7 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -45,12 +49,17 @@ public class RedisCache { private final StringRedisTemplate redisTemplate; - // 添加 JSR310 模块,以便顺利序列化 LocalDateTime 等类型 - private final ObjectMapper writeMapper = JsonMapper.builder() + private final ObjectMapper oldObjectMapper = JsonMapper.builder() .addModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build(); - private final ObjectMapper readMapper = JsonMapper.builder() - .addModule(new JavaTimeModule()) + + private final ObjectMapper objectMapper = JsonMapper.builder() + // 序列化添加类型信息,并信任 Redis 中内容的反序列化 + .activateDefaultTyping(LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY) + // 适配 Spring Security 权限验证中用到的类 + .addModules(SecurityJackson2Modules.getModules(null)) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build(); @@ -238,7 +247,7 @@ public T getCache(final String key, Class valueType, Supplier onMiss, return null; } } - result = readMapper.readValue(json, valueType); + result = readJsonValue(json, valueType); } catch (Exception e) { log.error(e.getMessage(), e); return null; @@ -262,7 +271,7 @@ public void updateCache(final String key, Class valueType, T defaultValue if (StringUtils.isEmpty(json)) { result = defaultValue; } else { - result = readMapper.readValue(json, valueType); + result = readJsonValue(json, valueType); } result = onUpdate.apply(result); setCache(key, result, timeout, timeUnit); @@ -389,7 +398,7 @@ public void syncRemoveCacheByPattern(String pattern) { private String getJson(T value) { String json; try { - json = writeMapper.writeValueAsString(value); + json = objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { if (log.isDebugEnabled()) { log.debug(e.getMessage(), e); @@ -398,4 +407,13 @@ private String getJson(T value) { } return json; } + + private T readJsonValue(String json, Class valueType) throws JsonProcessingException { + try { + return objectMapper.readValue(json, valueType); + } catch (InvalidTypeIdException e) { + // 缺少类型信息时,回退到旧版本 + return oldObjectMapper.readValue(json, valueType); + } + } } diff --git a/src/main/java/plus/maa/backend/service/UserService.java b/src/main/java/plus/maa/backend/service/UserService.java index 9c80e178..a3bbb696 100644 --- a/src/main/java/plus/maa/backend/service/UserService.java +++ b/src/main/java/plus/maa/backend/service/UserService.java @@ -53,11 +53,21 @@ public MaaLoginRsp login(LoginDTO loginDTO) { if (user == null || !passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) { throw new MaaResultException(401, "用户不存在或者密码错误"); } + // 待升级的密码编码方式 + if (passwordEncoder.upgradeEncoding(user.getPassword())) { + user.setPassword(passwordEncoder.encode(loginDTO.getPassword())); + userRepository.save(user); + } // 未激活的用户 if (Objects.equals(user.getStatus(), 0)) { throw new MaaResultException(MaaStatusCode.MAA_USER_NOT_ENABLED); } + return maaLoginRsp(user); + } + + @NotNull + public MaaLoginRsp maaLoginRsp(MaaUser user) { var jwtId = UUID.randomUUID().toString(); var jwtIds = user.getRefreshJwtIds(); jwtIds.add(jwtId); diff --git a/src/main/resources/application-template.yml b/src/main/resources/application-template.yml index 6d8ae4f3..b1cf2d33 100644 --- a/src/main/resources/application-template.yml +++ b/src/main/resources/application-template.yml @@ -11,6 +11,31 @@ spring: port: 6379 host: localhost +# security: +# oauth2: +# client: +# # OIDC 配置内容属于私密信息,需要妥善储存,这里只是样例 +# registration: +# maa-account: +# # 配置连接到 maa-account 所使用的 ID 和 secret +# client-id: $I_Am_The_Bone_Of_My_Sword!Steel_Is_My_Body_And_Fire_Is_My_Blood!$ +# client-secret: $I_Am_The_Bone_Of_My_Sword!Steel_Is_My_Body_And_Fire_Is_My_Blood!$ +# # 回调地址,需要在 maa-account 的回调 URL 白名单内,默认为 "/oidc/callback/{registrationId}" +# redirect-uri: ${maa-copilot.info.frontend-domain}/oidc/callback/maa-account +# # 验证客户和提供者的方法 +# client-authentication-method: client_secret_post +# # 授权模式 +# authorization-grant-type: authorization_code +# # 可访问的作用域 +# scope: +# - email +# - openid +# - profile +# provider: +# maa-account: +# # OpenID 配置颁发者 URL +# issuer-uri: $I_Am_The_Bone_Of_My_Sword!Steel_Is_My_Body_And_Fire_Is_My_Blood!$ + maa-copilot: backup: dir: /home/dove/copilotBak