diff --git a/chat_web_backend/pom.xml b/chat_web_backend/pom.xml index edd9eb0..1698a34 100644 --- a/chat_web_backend/pom.xml +++ b/chat_web_backend/pom.xml @@ -191,6 +191,17 @@ spring-boot-starter-security + + + org.springframework.security + spring-security-cas + + + org.jasig.cas.client + cas-client-core + 3.5.0 + + org.springframework.boot spring-boot-starter-data-jdbc diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/base/config/WebSecurityConfig.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/config/WebSecurityConfig.java index dc69971..4874328 100644 --- a/chat_web_backend/src/main/java/com/inspur/llm/chat/base/config/WebSecurityConfig.java +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/config/WebSecurityConfig.java @@ -7,6 +7,7 @@ import com.inspur.llm.chat.base.security.UserDetailsServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; @@ -27,6 +28,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic * Copyright Ⓒ 2024 Inspur Corporation Limited All rights reserved. */ @Configuration +@Order(100) @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasLoginSuccessHandler.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasLoginSuccessHandler.java new file mode 100644 index 0000000..3511607 --- /dev/null +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasLoginSuccessHandler.java @@ -0,0 +1,37 @@ +package com.inspur.llm.chat.base.security.cas; + +import com.inspur.llm.chat.base.security.JwtTokenUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * CAS 登录成功 → 生成 JWT → 重定向前端登录页(带 cas_token) + */ +@Slf4j +@Component +public class CasLoginSuccessHandler implements AuthenticationSuccessHandler { + + @Autowired + private CasProperties casProperties; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + log.info("CAS 登录成功: {}", authentication.getName()); + String token = JwtTokenUtils.generateToken(authentication); + String redirect = casProperties.getFrontEndUrl() + + "/#/login?cas_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name()); + response.sendRedirect(redirect); + } +} diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasProperties.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasProperties.java new file mode 100644 index 0000000..5ae1f96 --- /dev/null +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasProperties.java @@ -0,0 +1,24 @@ +package com.inspur.llm.chat.base.security.cas; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * CAS 单点登录配置(绑定 application.yml 中的 security.cas.*) + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "security.cas") +public class CasProperties { + private boolean enabled = false; + private String serverHost; + private String serverLogin; + private String serverLogout; + private String appLogin; + private String appLogout; + private String appKey; + private String appSecret; + private boolean httpsFlag = false; + private String frontEndUrl; +} diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasSecurityConfig.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasSecurityConfig.java new file mode 100644 index 0000000..79f8d91 --- /dev/null +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasSecurityConfig.java @@ -0,0 +1,101 @@ +package com.inspur.llm.chat.base.security.cas; + +import org.jasig.cas.client.validation.Cas20ServiceTicketValidator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken; +import org.springframework.security.cas.authentication.CasAuthenticationProvider; +import org.springframework.security.cas.web.CasAuthenticationEntryPoint; +import org.springframework.security.cas.web.CasAuthenticationFilter; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +/** + * CAS 单点登录 Security 配置(仅处理 /cas/** 路径,与 WebSecurityConfig 共存)。 + * + * 流程: + * /cas/login(无 ticket) → 触发 CasEntryPoint → 302 到 CAS 登录页 + * /cas/login?ticket=xxx → CasAuthenticationFilter 验证 → 成功 → CasLoginSuccessHandler 生成 JWT → 302 前端 + */ +@Configuration +@Order(1) +public class CasSecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired + private CasProperties casProperties; + + @Autowired + private AuthenticationUserDetailsService casUserDetailsService; + + @Autowired + private AuthenticationSuccessHandler casLoginSuccessHandler; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .antMatcher("/cas/**") + .csrf().disable() + .anonymous().disable() + .authorizeRequests() + .anyRequest().authenticated() + .and() + .exceptionHandling() + .authenticationEntryPoint(casAuthenticationEntryPoint()) + .and() + .addFilter(casAuthenticationFilter()); + http.headers().frameOptions().disable(); + } + + @Override + protected void configure(AuthenticationManagerBuilder auth) { + auth.authenticationProvider(casAuthenticationProvider()); + } + + @Bean + public ServiceProperties casServiceProperties() { + ServiceProperties sp = new ServiceProperties(); + sp.setService(casProperties.getFrontEndUrl() + "/chat_web_backend" + casProperties.getAppLogin()); + sp.setSendRenew(false); + sp.setAuthenticateAllArtifacts(true); + return sp; + } + + @Bean + public CasAuthenticationEntryPoint casAuthenticationEntryPoint() { + CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint(); + ep.setLoginUrl(casProperties.getServerLogin()); + ep.setServiceProperties(casServiceProperties()); + return ep; + } + + @Bean + public CasAuthenticationFilter casAuthenticationFilter() throws Exception { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setAuthenticationManager(authenticationManager()); + filter.setFilterProcessesUrl(casProperties.getAppLogin()); + filter.setAuthenticationSuccessHandler(casLoginSuccessHandler); + filter.setServiceProperties(casServiceProperties()); + // 仅在 URL 含 ticket 时才尝试认证;无 ticket → 让 ExceptionTranslationFilter 触发 EntryPoint 重定向 CAS + final String appLogin = casProperties.getAppLogin(); + filter.setRequiresAuthenticationRequestMatcher(req -> + appLogin.equals(req.getServletPath()) && req.getParameter("ticket") != null + ); + return filter; + } + + @Bean + public CasAuthenticationProvider casAuthenticationProvider() { + CasAuthenticationProvider provider = new CasAuthenticationProvider(); + provider.setAuthenticationUserDetailsService(casUserDetailsService); + provider.setServiceProperties(casServiceProperties()); + provider.setTicketValidator(new Cas20ServiceTicketValidator(casProperties.getServerHost())); + provider.setKey("casAuthProviderKey"); + return provider; + } +} diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasUserDetailsService.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasUserDetailsService.java new file mode 100644 index 0000000..b4a1677 --- /dev/null +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/base/security/cas/CasUserDetailsService.java @@ -0,0 +1,74 @@ +package com.inspur.llm.chat.base.security.cas; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.inspur.llm.chat.base.security.UserDetail; +import com.inspur.llm.chat.gpt.mapper.SysUserMapper; +import com.inspur.llm.chat.gpt.mapper.UserMapper; +import com.inspur.llm.chat.gpt.pojo.entity.SysUser; +import com.inspur.llm.chat.gpt.pojo.entity.User; +import com.inspur.llm.chat.gpt.pojo.vo.SysUserVO; +import com.inspur.llm.chat.gpt.pojo.vo.UserVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.HashSet; + +/** + * 把 CAS 返回的 principal 映射到本地用户: + * 1) sys_user.cas_username + * 2) gpt_user.cas_username + * 3) gpt_user.tel(fallback) + * 4) 都没找到 → 抛 UsernameNotFoundException + */ +@Slf4j +@Service +public class CasUserDetailsService implements AuthenticationUserDetailsService { + + @Autowired + private SysUserMapper sysUserMapper; + + @Autowired + private UserMapper userMapper; + + @Override + public UserDetails loadUserDetails(CasAssertionAuthenticationToken token) { + String casPrincipal = token.getName(); + log.info("CAS 用户加载: principal={}", casPrincipal); + + SysUser sysUser = sysUserMapper.selectOne( + Wrappers.lambdaQuery().eq(SysUser::getCasUsername, casPrincipal).last("limit 1")); + if (sysUser != null) { + log.info("CAS 命中 sys_user id={}", sysUser.getId()); + SysUserVO vo = new SysUserVO(); + BeanUtils.copyProperties(sysUser, vo); + return new UserDetail(vo, new HashSet<>()); + } + + User user = userMapper.selectOne( + Wrappers.lambdaQuery().eq(User::getCasUsername, casPrincipal).last("limit 1")); + if (user != null) { + log.info("CAS 命中 gpt_user (cas_username) id={}", user.getId()); + UserVO vo = new UserVO(); + BeanUtils.copyProperties(user, vo); + return new UserDetail(vo, new HashSet<>()); + } + + user = userMapper.selectOne( + Wrappers.lambdaQuery().eq(User::getTel, casPrincipal).last("limit 1")); + if (user != null) { + log.info("CAS 命中 gpt_user (tel fallback) id={}", user.getId()); + UserVO vo = new UserVO(); + BeanUtils.copyProperties(user, vo); + return new UserDetail(vo, new HashSet<>()); + } + + log.warn("CAS 用户未在战知库中找到: {}", casPrincipal); + throw new UsernameNotFoundException("CAS 用户未绑定战知账号: " + casPrincipal); + } +} diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/controller/app/LoginController.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/controller/app/LoginController.java index 33755dd..951cb84 100644 --- a/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/controller/app/LoginController.java +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/controller/app/LoginController.java @@ -22,7 +22,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -54,8 +55,8 @@ public class LoginController { * @date: 2024/1/9 * @version: 1.0.0 */ - @GetMapping("/api/oauth/token") - public ResponseInfo login(LoginCommand command) { + @PostMapping("/api/oauth/token") + public ResponseInfo login(@RequestBody LoginCommand command) { UsernamePasswordAuthenticationToken authenticationToken; if (UserTypeEnum.TEL.getValue().equals(command.getLoginType())) { UserVO user = userService.loginByTel(command.getTel(), command.getPassword(), command.getCode()).getData(); diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/SysUser.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/SysUser.java index 122b0ab..e02ccbc 100644 --- a/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/SysUser.java +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/SysUser.java @@ -70,6 +70,11 @@ public class SysUser extends BaseEntity { */ private String email; + /** + * CAS 单点登录用户名(钢研统一身份) + */ + private String casUsername; + /** * 性别 0->女;1->男 */ diff --git a/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/User.java b/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/User.java index c17c176..6c4122a 100644 --- a/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/User.java +++ b/chat_web_backend/src/main/java/com/inspur/llm/chat/gpt/pojo/entity/User.java @@ -74,6 +74,11 @@ public class User extends BaseEntity { */ private String unionid; + /** + * CAS 单点登录用户名(钢研统一身份) + */ + private String casUsername; + /** * 登录ip */ diff --git a/chat_web_backend/src/main/resources/application.yml b/chat_web_backend/src/main/resources/application.yml index 0b5b5ca..3f2100d 100644 --- a/chat_web_backend/src/main/resources/application.yml +++ b/chat_web_backend/src/main/resources/application.yml @@ -92,4 +92,17 @@ xss: # 词向量模型名称 embedding-model-name: bge_m3 +# CAS 单点登录配置 +security: + cas: + enabled: true + server-host: http://192.168.203.20:8180 + server-login: ${security.cas.server-host}/login + server-logout: ${security.cas.server-host}/logout + app-login: /cas/login + app-logout: /cas/logout + app-key: 8UyEQdr6L6FfAH3D + app-secret: Ely1U6Uv2Nf8APQSfZdg1epSbQ6ORKwr + front-end-url: http://192.168.203.8:3000 + diff --git a/chat_web_front/src/api/index.ts b/chat_web_front/src/api/index.ts index fb85922..4a846aa 100644 --- a/chat_web_front/src/api/index.ts +++ b/chat_web_front/src/api/index.ts @@ -8,9 +8,9 @@ export function fetchChatConfig() { }) } -// 登录 +// 登录(POST + body,避免密码出现在 URL/access log) export function fetchVerify(data: object) { - return get({ + return post({ url: '/app/api/oauth/token', data, }) diff --git a/chat_web_front/src/views/login/index.vue b/chat_web_front/src/views/login/index.vue index d136bbe..04ba1e2 100644 --- a/chat_web_front/src/views/login/index.vue +++ b/chat_web_front/src/views/login/index.vue @@ -56,6 +56,14 @@ {{ loginTip }} + + + 通过统一身份登录 + + @@ -139,6 +147,22 @@ const handleVerify = async () => { } }; +// 跳后端 CAS 入口;后端 302 跳 CAS 服务器 +const goCasLogin = () => { + window.location.href = `/chat_web_backend/cas/login`; +}; + +// 处理 CAS 回调:URL 含 cas_token 参数 → 存 token 后跳首页 +onMounted(() => { + const params = new URLSearchParams(window.location.search); + const casToken = params.get("cas_token"); + if (casToken) { + authStore.setToken(casToken); + window.history.replaceState({}, document.title, window.location.pathname + window.location.hash.split("?")[0]); + router.push("/chat"); + } +}); +