Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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 @@ -36,6 +36,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,92 @@
package plus.maa.backend.config.security;

import cn.hutool.core.lang.Assert;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
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.context.annotation.Lazy;
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;
@Lazy // 解决循环依赖
@Resource
private 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);
}
// 响应登录数据
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,104 @@
package plus.maa.backend.config.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Getter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
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 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
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;

// 不使用 RedisCache 是因为 Jackson 默认无法反序列化 OAuth2AuthorizationRequest
private final RedisTemplate<String, OAuth2AuthorizationRequest> redisTemplate;

public RedisOAuth2AuthorizationRequestRepository(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, OAuth2AuthorizationRequest> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// Key 使用字符串类型的序列化程序,其他则使用 Java 序列化程序
redisTemplate.setDefaultSerializer(RedisSerializer.java());
redisTemplate.setKeySerializer(RedisSerializer.string());
// 手动初始化
redisTemplate.afterPropertiesSet();
this.redisTemplate = redisTemplate;
}

@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");
String serial = UUID.randomUUID().toString();
request.setAttribute(REQUEST_KEY, serial);
redisTemplate.opsForValue().set(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) {
redisTemplate.delete(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 redisTemplate.opsForValue().get(REDIS_KEY_PREFIX + serial);
}
}
31 changes: 29 additions & 2 deletions src/main/java/plus/maa/backend/config/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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,6 +72,9 @@ 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() {
Expand Down Expand Up @@ -98,12 +103,34 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//此处用于管理员操作接口
.requestMatchers(URL_AUTHENTICATION_2).hasAuthority("2")
.anyRequest().authenticated());

http.oauth2Login(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);
});

//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

//配置异常处理器,处理认证失败的JSON响应
http.exceptionHandling(exceptionHandling -> exceptionHandling.authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler));

//开启跨域请求
http.cors(withDefaults());
return http.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions src/main/java/plus/maa/backend/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ public MaaLoginRsp login(LoginDTO loginDTO) {
throw new MaaResultException(401, "用户不存在或者密码错误");
}

return maaLoginRsp(user);
}

@NotNull
public MaaLoginRsp maaLoginRsp(MaaUser user) {
var jwtId = UUID.randomUUID().toString();
var jwtIds = user.getRefreshJwtIds();
jwtIds.add(jwtId);
Expand Down
25 changes: 25 additions & 0 deletions src/main/resources/application-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down