Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/plus/maa/backend/common/utils/WebUtils.java
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<MaaLoginRsp> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<OIDCInfo> result = MaaResult.success(oidcInfo);
String json = objectMapper.writeValueAsString(result);
WebUtils.renderString(response, json, 200);
}
}
Original file line number Diff line number Diff line change
@@ -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<OAuth2AuthorizationRequest> {

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);
}
}
81 changes: 76 additions & 5 deletions src/main/java/plus/maa/backend/config/security/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<String, PasswordEncoder>();
encoders.put(BCRYPT, new BCryptPasswordEncoder());

// 创建委托密码编码器,指定用于密码编码的编码器 id,以及被委托的编码器映射
var delegating = new DelegatingPasswordEncoder(BCRYPT, encoders);

// 兼容旧数据中直接裸使用 BCrypt 编码器的密码
delegating.setDefaultPasswordEncoderForMatches(encoders.get(BCRYPT));
return delegating;
}
}

@Bean
Expand All @@ -98,14 +128,55 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//此处用于管理员操作接口
.requestMatchers(URL_AUTHENTICATION_2).hasAuthority("2")
.anyRequest().authenticated());


// 存在 Maa Account 配置时,才启用 OIDC
Customizer<OAuth2LoginConfigurer<HttpSecurity>> 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<OAuth2LoginConfigurer<HttpSecurity>> 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);
};
}
}
Loading