1 总则
1.1 编写目的
为了开发人员更好理解 CAS 统一登录流程指导开发。
1.2 读者对象
开发人员
2 后端接入指南
2.1 oauth 认证模块
2.1.1 修改依赖版本
<dependency>
<groupId>com.unicom</groupId>
<artifactId>spring-unicom-cloud-oauth2-server-stater</artifactId>
<version>2.2.1.SSO</version>
</dependency>
2.1.2 数据库修改配置
update oauth_client_details set authorized_grant_types = 'authorization_code,password,refresh_token,client_credentials,implicit,password_code,openId,mobile_password,cas_server' where id = 1;
2.2 统一登录
2.2.1 添加依赖
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-support-springboot</artifactId>
<version>3.6.4</version>
</dependency>
2.2.2 启动类配置
@SpringBootApplication
@EnableCasClient
public class BaseAdminApplication {
public static void main(String[] args) {}
}
2.2.3 添加配置项
#cas服务端地址
cas.server-url-prefix=https://localhost:8888/cas
##cas服务端登录地址
cas.server-login-url=https://localhost:8888/cas/login
##客户端地址
cas.client-host-url=https://127.0.0.1:8080
#拦截cas登录认证
cas.validation-url-patterns=/casLogin
cas.authentication-url-patterns=/casLogin
cas.single-logout.enabled=true
server.servlet.session.timeout=7200
cas.use-session=true
callback=${cas.server-url-prefix}/logout?service=
logout.service=http://127.0.0.1:8080/casLogoutAfter
2.2.4 编写 controller
/**
* 登录接口
*
* @return
*/
@ApiOperation(value = "CAS系统登录")
@RequestMapping(value = "/casLogin", method = RequestMethod.GET)
public HttpResult login(HttpServletRequest request, HttpServletResponse response, @RequestParam("redirect") String url) throws Exception {
Principal principal = request.getUserPrincipal();
Map<String, Object> attributes = ((AttributePrincipal) principal).getAttributes();
String username = (String) attributes.get("username");
if(StringUtils.isEmpty(username)){
username = (String) attributes.get("userName");
}
/*增加cas通oauth的加密认证机制*/
String password = "";
try {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
// 用户信息
Map<String, String> parameters = Maps.newHashMap();
parameters.put("username", username);
parameters.put("password", password);
parameters.put("tenant_code", "0000");
parameters.put("client_id", securityProperties.getAuth().getClientId());
parameters.put("grant_type", "cas_server");
parameters.put("client_secret", securityProperties.getAuth().getClientSecret());
// 请求用户中心获取token
ResponseEntity<OAuth2AccessToken> entity = authService.postAccessToken(token, parameters);
if (entity == null) {
return HttpResult.FAIL_EXCEPTION("账号或密码错误");
}
LoginUser loginBean = new LoginUser();
loginBean.setUsername(username);
OAuth2AccessToken auth2AccessToken = entity.getBody();
// token 加入到缓存中
loginBean.setToken(auth2AccessToken.getAccess_token());
tokenService.setUserAgent(loginBean);
tokenService.refreshToken(loginBean);
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, "登录成功!"));
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", url + (url.contains("?") ? "&" : "?") + "token=" + auth2AccessToken.getAccess_token());
response.setHeader("Set-Cookie", "token="+auth2AccessToken.getAccess_token()+"; path=/; expires="+attributes.get("expiredDate")+"; HttpOnly; ");
return HttpResult.OK(auth2AccessToken);
} catch (Exception e) {
// log.error("cas登录回调失败:{}", e);
if (e instanceof BadCredentialsException) {
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, "账号或密码错误!"));
return HttpResult.FAIL_EXCEPTION("账号或密码错误");
} else {
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
return HttpResult.FAIL_EXCEPTION("账号或密码错误");
}
}
}
前后端分离模式,前端使用 window.location,跳转到/casLogin 接口带上 redirect 参数,注意 redirect 参数需要进行 encode 编码,代表登录完成之后需要回跳到那个地址,配置了 cas.validation-url-patterns 和 cas.authentication-url-patterns 两项配置之后,cas 会拦截这个接口,如果没有登录,会自动跳转到 cas 登录页面,登录完成之后,cas-serve 会回调这个接口,这个时候就可以获取到用户信息,并且做自己系统的鉴权。
2.3 统一登出
2.3.2 编写 Controller
@Value("${callback}")
private String callback;
@Value("${logout.service}")
private String service;
@Value("${cas.client-host-url}")
private String localService;
@ApiOperation(value = "退出登录")
@RequestMapping(value = "/casLogoutPre", method = RequestMethod.GET)
public HttpResult casLogoutPre(HttpServletRequest request ,HttpServletResponse response,@RequestParam("token") String token,@RequestParam("redirect") String url) throws IOException {
if(StringUtils.isEmpty(token) || UNDEFINED.equals(token)){
response.sendRedirect(localService+"/admin/casLogin?redirect="+url);
return HttpResult.OK();
}
DecodedJWT jwt = JWT.decode(token);
Map<String, Claim> claims = jwt.getClaims();
Claim exp = claims.get("exp");
Date date = exp.asDate();
String userName = claims.get("user_name").asString();
if ((date != null && date.before(new Date()))) {
HttpResult.FAIL_EXCEPTION("登陆状态已过期");
}
HttpSession session = request.getSession();
session.invalidate();
String redirect = callback+ URLEncoder.encode(service+"?username="+userName+"&redirect="+url,"utf-8");
response.sendRedirect(redirect);
return HttpResult.OK();
}
@ApiOperation(value = "退出登录")
@RequestMapping(value = "/casLogoutAfter", method = RequestMethod.GET)
public HttpResult casLogoutAfter(HttpServletRequest request,HttpServletResponse response,@RequestParam("username") String username,@RequestParam("redirect") String url) {
SysUser user = sysUserService.selectUserByUserName(username);
if (user != null) {
tokenService.delToken(Constants.LOGIN_TOKEN_KEY + user.getUserName());
// 记录用户退出日志
logininforService.logout(user.getUserName());
}
HttpSession session = request.getSession();
session.invalidate();
CookieUtil.delCookie("SESSION");
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location",url);
return HttpResult.OK();
}
前端使用 window.location 跳转到/casLogoutPre,此时的参数里面包含 redirect,同样表示为退出登录之后跳转的地址,然后从/casLogoutPre 直接进行重定向,重定向到退出页面的地址"https://localhost:8888/cas/logout?service="+URLEncoder.encode("https://127.0.0.1:8080/casLogoutAfter?redirect="+url,"utf-8") service 之后拼接的参数为/casLogoutAfter 的地址,同时把 redirect 参数也带过来,在 casLogoutAfter 中进行重定向到退出之后的页面,同时也可以在这里把自己系统的鉴权信息清除。
特别注意:在登录的过程中是使用后端重定向到 cas-serve,所以在 cas-serve 中记录的为后端的地址,在退出的时候,也就需要后端去进行一次重定向,这样才能成功匹配登录的信息进行清除。
前端应用(代码)
- vue3 版本,如 vue2 应用可按此逻辑修改
方法封装
- utils/sso/index.js
//sso地址配置
import { ssoEnum, ssoDevEnum, ssoTestEnum } from '/@/enums/ssoEnum';
<!-- 判断环境 -->
import { isProdMode, isDevMode, isTestMode } from '/@/utils/env';
//用户获取token,根据项目不同按需修改
import { useUserStoreWithOut } from '/@/store/modules/user';
export function getSSOConf() {
// 返回 SSO登录页地址
try {
if (isTestMode()) {
return ssoTestEnum;
} else if (isDevMode()) {
return ssoDevEnum;
} else if (isProdMode()) {
return ssoEnum;
} else {
throw Error('未知环境');
}
} catch (error) {
console.error(error);
}
}
/**
* @description 获取 SSO登录页地址
* @returns string
*/
export function getSSOPageURL(redirect = window.location.href) {
// 返回 SSO登录页地址
const url = getSSOConf();
const serviceEncoded = `${encodeURIComponent(redirect)}`;
return `${url?.SSO_LOGIN}${serviceEncoded}`;
}
/**
@description: 获取退出登录地址
@return string 浏览器跳转URL地址
*/
export function getLogoutPageURL() {
const url = getSSOConf();
const userStore = useUserStoreWithOut(); // 用户信息
const redirectEncoded = encodeURIComponent(window.location.href); // encode当前页面地址
const token = userStore.getToken; // 用户token
return `${url?.SSO_LOGOUT}?redirect=${redirectEncoded}&token=${token}`;
}
/**
* @description 跳转到 SSO登录页
* @returns void
*/
export function JumpToSSOPage(redirect?: string) {
window.location.href = getSSOPageURL(getCurrentPageFullURL(redirect));
}
/**
@description: 退出登录跳转
@return void
*/
export function JumpToLogoutPage() {
if (hasToken()) {
// 退出登录 跳转
window.location.href = getLogoutPageURL();
} else {
// 未登录 跳转到 SSO登录页
JumpToSSOPage();
}
}
/**
* @description 检查是否存在token
* @returns boolean
*/
export function hasToken() {
const userStore = useUserStoreWithOut();
const token = userStore.getToken;
if (token) {
return true;
} else {
return false;
}
}
/**
* @description 如果未登录 跳转到 SSO登录页 已登录则返回true
* @returns boolean
*/
export function checkLogin(redirect?: string) {
if (hasToken()) {
return true;
} else {
JumpToSSOPage(redirect);
return false;
}
}
/**
* @description 返回当前页面 完整url
* @returns string
*/
export function getCurrentPageFullURL(path?: string) {
if (path && !path.includes('http')) {
return `${window.location.origin}${path}`;
} else {
return window.location.href;
}
}
- ssoEnum.ts 登录页配置
export enum ssoEnum {
// 生产环境 登录地址
SSO_LOGIN = '',
// 生产环境 登录地址
SSO_LOGOUT = '',
}
export enum ssoDevEnum {
// 开发环境 service 参数
SSO_LOGIN = 'http://192.168.5.12:8080/admin/casLogin?redirect=',
// 开发环境 退出登录 url ?redirect=&token=
SSO_LOGOUT = 'http://192.168.5.12:8080/admin/casLogoutPre',
}
export enum ssoTestEnum {
// 测试环境 service 参数
SSO_LOGIN = 'https://admin.uni-links.com.cn/admin/casLogin?redirect=',
// 测试环境 退出登录 url ?redirect=&token=
SSO_LOGOUT = 'https://admin.uni-links.com.cn/admin/casLogoutPre',
}
- utils/env
/**
* @description: Is it a development mode
* @returns:
* @example:
*/
export function isDevMode(): boolean {
return import.meta.env.DEV;
}
/**
* @description: Is it a production mode
* @returns:
* @example:
*/
export function isProdMode(): boolean {
return import.meta.env.PROD;
}
/**
* @description: Is it a test mode
* @returns:
* @example:
*/
export function isTestMode(): boolean {
return import.meta.env.VITE_MODE_NAME === "test";
}
使用(前端)
import { JumpToSSOPage, getCurrentPageFullURL } from '/@/utils/sso';
router.beforeEach(async (to, from, next) => {
...
if (to.query.token) {
const query = to.query;
const queryWithOutToken = Object.keys(query).reduce((acc, cur) => {
if (cur !== 'token') {
acc[cur] = query[cur];
}
return acc;
}, {} as Recordable);
userStore.setToken(to.query.token as string);
if (to.query.redirect) {
const redirectPath = to.query.redirect as string;
next({ path: redirectPath, query: queryWithOutToken });
return;
} else {
// next({ path: ROOT_PATH });
next({ path: to.path, query: queryWithOutToken });
return;
}
}
...
if (!token) {
...
next(false);
JumpToSSOPage(getCurrentPageFullURL(to.fullPath));
...
}
})
- 退出登录方法
import { JumpToLogoutPage } from '/@/utils/sso';
async logout() {
// 跳转SSO 退出登录页
JumpToLogoutPage();
....
}