SpringBoot项目,Shiro安全框架集成KeyCloak实现SSO单点登录。最终实现的效果是可以KeyCloak登陆也可以本地登陆两种方式并存。

一、封装一个实体类

首先,因为要兼容两种登录方法,但是两种方式都要用shiro。所以自己创建了一个实体类,继承shiro的认证对象。在这个对象里面userRoles只有keycloak才有用,其它的两种登录方法都公用。里面有两个构造函数,在本地登录时调用第一个,然后创建的type是2,keycloak登录的时候调用的是第二个构造函数,type是1。如果你原项目已经集成了安全框架有了一个这样的实体类,最好不要在原来的实体类修改,最好创建一个新的,因为不要太多的修改原来已有的安全框架流程,集成KeyCloak可以理解为新加功能,不会影响原来已有的功能。

//继承UsernamePasswordToken 传入给shiro时需要这个类或者是继续这个类的类
@Data
public class CustomUserTokenBean extends UsernamePasswordToken {

    public static final String TYPE_KEYCLOAK = "1";
    public static final String TYPE_LOCAL = "2";

    private String id;
    private String username;
    private char[] password;
    private String passwordStr;
    private String type;
    private String salt;
    List<String> userRoles;

    public CustomUserTokenBean( String username, String password ){//本地登录的构造函数,创建对象时,type是2
        this.username = username;
        this.password = (char[])(password != null ? password.toCharArray() : null);
        this.passwordStr = password;//把密码转成String赋值给passwordStr,因为现在是单字符数组所以在MD5加密的时候会和shiro的字符串加密的结果不一样
        this.type = TYPE_LOCAL;
    }

    public CustomUserTokenBean( String id, String username, String password, List<String> userRoles){//keycloak的构造函数,创建对象时,type是1
        this.id = id;
        this.username = username;
        this.password = (char[])(password != null ? password.toCharArray() : null);
        this.passwordStr = password;
        this.userRoles = userRoles;
        this.type = TYPE_KEYCLOAK;
    }

}

登陆接口的判断

需要一个登陆的接口,这个接口判断当前默认那种方式的登陆就再重定向执行对应的登陆接口。

@GetMapping("/api/login")
    public Object redirectForLogin(String subUrl){
        if( "1".equals(isOpen) ){// 0 关闭keycloak登陆 走本地登陆, 1开启keycloak登陆 走keycloak登陆
            //走keycloak的登陆接口
            return "redirect:/api/v2/sysLogin"+(StringUtils.isNotEmpty(subUrl)?"?subUrl="+subUrl:"");
        }
        return "redirect:"+localRedirect;//走项目的本地登陆接口
    }

本地登录接口

本地的登陆接口和大多数shiro集成springboot框架是一样的流程,最后的cookie返回的数据根据自己项目需求返回就行。

@PostMapping(value = "/api/v1/sysLogin")
    public Object login(HttpServletRequest request, HttpServletResponse response, @RequestBody String json, String loginCode, String password) {
        sysUserService.login(json, loginCode, password);
        //登录成功之后跳转到原url
        String url = "/";
        SavedRequest savedRequest = WebUtils.getSavedRequest(request);
        if (savedRequest != null) {
            url = savedRequest.getRequestUrl();
        }

        Map<String, String> cookieMap = new HashMap<>();//保存在token的数据
        cookieMap.put("userAgent", request.getHeader("user-agent"));
        cookieMap.put("remoteAddr", request.getRemoteAddr());
        cookieMap.put("uuid", UUID.randomUUID().toString());
        cookieMap.put("sessionId", ShiroUserUtils.getSessionId());
        
        String access_token = AesCBCUtil.encrypt(AesCBCUtil.encrypt(JSONObject.toJSONString(cookieMap)));//这里生成token

        Cookie cookie = new Cookie("access_token", access_token);
        cookie.setHttpOnly(false);
        cookie.setPath("/");
        response.addCookie(cookie);

        cookie = new Cookie("user_id", ShiroUserUtils.getUser().getUserId());
        cookie.setHttpOnly(false);
        cookie.setPath("/");
        response.addCookie(cookie);
        //存入cookie的字符串不能有空格
        cookie = new Cookie("user_name", ShiroUserUtils.getUser().getUserName());
        cookie.setHttpOnly(false);
        cookie.setPath("/");
        response.addCookie(cookie);

        cookie = new Cookie("sys_personal_id", ShiroUserUtils.getSysPersonalId());//获取这个用户的人员id
        cookie.setHttpOnly(false);
        cookie.setPath("/");
        response.addCookie(cookie);

        Map<String, String> resultData = new HashMap<>();
        resultData.put("url", url);
        resultData.put("access_token", access_token);
        resultData.put("user_id", ShiroUserUtils.getUser().getUserId());
        resultData.put("user_name", ShiroUserUtils.getUser().getUserName());
        resultData.put("sys_personal_id", ShiroUserUtils.getSysPersonalId());

        return RequestUtils.printMapJson(SysExceptionCode.CODE_10001.getCode(), resultData);
    }

本地的登录接口调用的shiro登陆方法,做一些非空判断,密码大小判断等,然后没问题再调用subject.login(UsernamePasswordToken对象)方法进行这个账号的认证和授权。

@Service
public class SysUserServiceImpl extends BaseServiceImpl<SysUser, Serializable> implements SysUserService {


    @Override
    public Object login(String json, String loginCode, String password) {
        Subject subject = SecurityUtils.getSubject();//创建subject实例
        if (subject.isAuthenticated() == false) {
            if (json != null) {
                try {
                    Map<String, String> loginMap = JSONObject.parseObject(json, Map.class);
                    if (loginMap != null) {
                        if (loginCode == null || loginCode.equals("")) {
                            loginCode = loginMap.get("loginCode");
                        }
                        if (password == null || password.equals("")) {
                            password = loginMap.get("password");
                        }
                    }
                } catch (Exception e) {
                }
            }
            if (password != null && password.length() > 18) throw new SysException(SysExceptionCode.CODE_40046);
            //将用户名和密码存入 UsernamePasswordToken 中
            UsernamePasswordToken token = new CustomUserTokenBean(loginCode, password);
            try {//需要捕获认证和授权返回的异常
                //将存有用户名和密码的token存进subject中 然后完成登录认证和授权
                subject.login(token);
            } catch (UnknownAccountException e) {
                throw new SysException(SysExceptionCode.CODE_40008);//用户名或者密码不能为空
            } catch (IncorrectCredentialsException e) {
                throw new SysException(SysExceptionCode.CODE_40009);//用户名或者密码不正确
            } catch (LockedAccountException e) {
                throw new SysException(SysExceptionCode.CODE_40010);//用户状态异常,请联系管理员
            } catch (AuthenticationException e) {
                throw new SysException(SysExceptionCode.CODE_40010);//用户状态异常,请联系管理员
            } catch (Exception e) {
                throw new SysException(SysExceptionCode.CODE_40005, e.getMessage());// ”“
            }
        }
        return ShiroUserUtils.getUser();
    }

}

shiro的登录接口

这里我不会详细说KeyCloak的功能和它的特性。你需要知道keycloak的原理以及怎么集成SpringBoot,我后面会单独发一篇KeyCloak文章和SpringBoot集成KeyCloak文章。
shiro登录接口只需要一个HttpServletRequest 即可,因为在请求这个接口之前它会被自动拦截跳转到KeyCloak的登录页面进行登录,登录成功之后HttpServletRequest 里面有需要的所有信息。

@GetMapping("/api/v2/sysLogin")
    public Object login(String subUrl, HttpServletRequest request){
        String tokenUid = keycloakService.login(request);
        return "redirect:"+frontendBaseUrl+"?tokenUid="+tokenUid
                + (StringUtils.isNotEmpty(subUrl)?"&subUrl="+subUrl:"");
    }

这个方法就是获取请求头里面的一个RefreshableKeycloakSecurityContext对象,这个是登录完KeyCloak之后KeyCloak放到请求头里面的。然后就是获取这个对象,一层一层往下拿到里面的accessToken 和refreshToken ,

public String login(HttpServletRequest request){
        try {
        	//获取请求头里面的RefreshableKeycloakSecurityContext对象
            KeycloakPrincipal<RefreshableKeycloakSecurityContext> keycloakPrinciple = keycloakManager.getKeycloakPrinciple(request);
            //获取RefreshableKeycloakSecurityContext对象里面的getKeycloakSecurityContext
            RefreshableKeycloakSecurityContext context = keycloakPrinciple.getKeycloakSecurityContext();
            //获取context里面的accessToken和refreshToken 
            String accessToken = context.getTokenString();
            String refreshToken = context.getRefreshToken();
            //创建一个uuid
            String tokenUid = UUID.randomUUID().toString();
            //verifyToken(accessToken)方法就是访问KeyCloak校验这个accessToken真实性
            if(!keycloakManager.verifyToken(accessToken)){
            	return "";
            }
            //没问题就把uuid和这个对象信息缓存起来(可以放redis),登录完成之后把这个uuid返回给前端,前端根据uuid再请求获取token信息
            CustomToken tokenObject = new CustomToken(keycloakPrinciple.getName(),accessToken,refreshToken);
            heapCacheManager.set(tokenUid,tokenObject);
            return tokenUid;
        } catch (Exception e){
            e.printStackTrace();
        }
        return "";
    }

KeyCloak登录完之后,重定向到一个前端页面,后面带上参数uuid,然后前端根据这个uuid访问获取token然后保存在请求头。currentUser.login(userToken)就是告诉shiro,创建一个CustomUserTokenBean给shiro让shiro做认证逻辑。可以看到,这个方法返回的信息和本地登录的是一样的,只在cookid多加了一个cookieMap.put(“keycloakToken”, customToken.getAccessToken()),后面拦截器的逻辑需要根据这个keycloakToken是否为空

@ResponseBody
    @PostMapping("/api/v2/token/get")
    public Object getToken(@RequestBody String json, HttpServletRequest request, HttpServletResponse response){
        Map<String, String> loginMap = JSONObject.parseObject(json, Map.class);
        CustomToken customToken = keycloakService.getToken(loginMap.get("tokenUid"));
        if(customToken == null)
            throw new SysException(SysExceptionCode.CODE_40012);

        CustomUserInfo userInfo = keycloakService.getUserInfo(customToken.getAccessToken());
        if( userInfo != null ){
            Subject currentUser = SecurityUtils.getSubject();
            CustomUserTokenBean userToken = new CustomUserTokenBean(userInfo.getId(),userInfo.getUsername(),CustomUserInfo.DEFAULT_PASSWORD,userInfo.getUserroles());
            userToken.setSalt(UUID.randomUUID().toString());
            try {
                // login 方法关联到 customRealm 的认证的方法
                //和原本shirl的登录方法不执行,在这里直接插入告诉shirl完成认证
                currentUser.login(userToken);
            } catch (Exception e){
                currentUser.logout();
            }
        }
        ShiroUserUtils.getCurrentUser();
        // 保证跟原来的登录接口返回一样的内容
        Map<String, String> cookieMap = new HashMap<>();//保存在token的数据
        cookieMap.put("userAgent", request.getHeader("user-agent"));
        cookieMap.put("remoteAddr", request.getRemoteAddr());
        cookieMap.put("uuid", UUID.randomUUID().toString());
        cookieMap.put("sessionId", ShiroUtils.getSessionId());
        cookieMap.put("keycloakToken", customToken.getAccessToken());
        String access_token = AesCBCUtil.encrypt(AesCBCUtil.encrypt(JSONObject.toJSONString(cookieMap)));//这里生成token
        Cookie cookie = new Cookie("access_token", access_token);
        cookie.setHttpOnly(false);
        cookie.setPath("/");
        response.addCookie(ObjectUtils.clone(cookie));
        //存入cookie的字符串不能有空格
        cookie = new Cookie("user_name", userInfo.getUsername());
        cookie.setHttpOnly(false);
        cookie.setPath("/");
        response.addCookie(ObjectUtils.clone(cookie));
        Map<String, String> resultData = new HashMap<>();
        resultData.put("access_token", access_token);
        resultData.put("user_name", userInfo.getUsername());
        return RequestUtils.printMapJson(SysExceptionCode.CODE_10001.getCode(), resultData);
    }

shiro的认证方法

想要完成shiro集成项目,必须要先重写认证和授权方法。认证就是判断当前登录的用户输入的账号密码和数据库里面的是否一致或者是否存在的校验。可以看到CustomUserTokenBean 对象就是一开始定义的对象,因为继承了UsernamePasswordToken所以可以强转。然后就是根据前面传的userToken实体类里面的type判断需要执行哪个认证的逻辑,可以看到,无论做那种逻辑,最后return的对象和里面的参数都是一样的。

/**
     * 认证方法
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        CustomUserTokenBean userToken = (CustomUserTokenBean) token;
        // 区分两种登录逻辑
        if( CustomUserTokenBean.TYPE_LOCAL.equals(userToken.getType()) ){//本地登录逻辑
            // local 登录
            QueryFilter queryFilter = new QueryFilter();
            queryFilter.and("loginCode", userToken.getUsername()).and("status", Relation.NOT_EQUAL, 1).limit(0, 1);
            SysUser sysUser = sysUserService.find(queryFilter);

            if (userToken.getUsername() == null || userToken.getUsername().equals("")) {
                throw new UnknownAccountException();
            }
            if (userToken.getPassword() == null || userToken.getPassword().equals("")) {
                throw new UnknownAccountException();
            }
            if (sysUser == null) {
                throw new IncorrectCredentialsException();
            }
            if (sysUser.getStatus() != 0) {
                throw new LockedAccountException();
            }
            sysUser.setLastLoginDate(new Date());
            sysUser.setLastLoginIp(Utils.getRequestIp());
            sysUserService.update(sysUser);
            //参数:用户输入的账号密码对象、我们去数据库查询的密码、userToken对象里面密码需要加密的盐、固定值getName()
            return new SimpleAuthenticationInfo(userToken,sysUser.getPassword(),ByteSource.Util.bytes(sysUser.getSalt()), getName());
        }
        // keycloak 登录
        //参数 用户输入的账号密码对象、需要比较的密码、serToken对象里面密码需要加密的盐、固定值getName()
        return new SimpleAuthenticationInfo(userToken,Utils.md5(userToken.getPasswordStr(),userToken.getSalt()),ByteSource.Util.bytes(userToken.getSalt()),getName());
    }

shiro的授权方法

每一个用户都需要进行登录认证,登录完之后就是授权。授权方法第一步和认证的一样,先获取到我们自己定义的实体类,有了自己定义的实体类里面的信息之后我们才好进行认证和授权的。授权就是根据当前登录的用户有哪些角色,这些角色对应有哪些可以访问的路径,shiro需要知道这两个事情之后才能帮我们做权限拦截。authorizationInfo.setRoles(拥有的角色set)是设置告诉shirl当前登录用户有哪些角色,authorizationInfo.setStringPermissions(允许访问路径的set)是设置告诉shirl当前用户有哪些链接可以访问。然后返回return authorizationInfo,授权方法结束。

/**
     * 授权方法,如果不设置缓存管理的话,需要访问需要一定的权限或角色的请求时会进入这个方法
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //principals强转成自己定义的CustomUserTokenBean类
        CustomUserTokenBean userToken = (CustomUserTokenBean) principals.getPrimaryPrincipal();
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();//授权对象
        Set<String> roleSet = new HashSet<>();
        Set<String> roleIdSet = new HashSet<>();
        Set<String> permiSet = new HashSet<>();//可以访问的链接
        if( CustomUserTokenBean.TYPE_KEYCLOAK.equals(userToken.getType()) ){
            for( String roleName : userToken.getUserRoles() ){
                SysRole paramRole = new SysRole();
                paramRole.setRoleName(roleName);
                queryFilter.and("status",Relation.NOT_EQUAL,1);
                queryFilter.and("roleName",roleName);
                List<SysRole> dataRoles = sysRoleService.findList(paramRole);
                if( dataRoles != null && !dataRoles.isEmpty() ){
                    dataRoles.forEach(dataRole->{
                        roleSet.add(dataRole.getRoleSign());//保存所有角色标识
                        roleIdSet.add(dataRole.getRoleId());//保存所有角色id
                    });
                }
            }
        }else{
            SysUser sysUser = sysUserService.get(userToken.getId());
            SysUserRole sysUserRole = new SysUserRole();
            sysUserRole.setUserId(sysUser.getUserId());
            List<SysUserRole> sysUserRoleList = sysUserService.findManyList(SysUserRole.class, sysUserRole);
            if (sysUserRoleList != null && sysUserRoleList.size() != 0) {
                for (SysUserRole userRole : sysUserRoleList) {
                    roleSet.add(userRole.getSysRole().getRoleSign());
                    roleIdSet.add(userRole.getRoleId());
                }
            }
        }
        //在这里获取keycloak的用户角色,然后根据角色查询mysql对应的角色id
        if (roleIdSet.size() != 0) {
            String sql = "select `get`, `post`, `put`, `delete`, `url` from sys_role_menu brm inner join sys_menu bm on brm.menu_id = bm.id where url != '' and url is not null and brm.role_id in ?";
            queryFilter.clear();
            queryFilter.putArgs(roleIdSet.toArray());
            List<Map<String, Object>> sysMenuMapList = sysUserService.findMapList(sql, queryFilter);//获取这个用户的所有角色和所有角色的权限
            if (sysMenuMapList != null && sysMenuMapList.size() != 0) {
                for (Map<String, Object> dataMap : sysMenuMapList) {
                    String url = dataMap.get("url").toString();
                    String get = dataMap.get("get").toString();
                    String post = dataMap.get("post").toString();
                    String put = dataMap.get("put").toString();
                    String delete = dataMap.get("delete").toString();
                    if (get.equals("1")) permiSet.add("get:" + url);//添加可以访问的链接
                    if (post.equals("1")) permiSet.add("post:" + url);//添加可以访问的链接
                    if (put.equals("1")) permiSet.add("put:" + url);//添加可以访问的链接
                    if (delete.equals("1")) permiSet.add("delete:" + url);//添加可以访问的链接
                }
            }
        }
        authorizationInfo.setRoles(roleSet);//设置告诉shirl有哪些角色
        authorizationInfo.setStringPermissions(permiSet);//设置告诉shirl有哪些链接可以访问
        return authorizationInfo;
    }

拦截器

前面我们已经配置好了认证和授权方法,相当于我们已经知道了当前用户登录的所有信息,那么知道这些信息之后我们就要对访问的接口进行拦截然后判断了。我们自己定义一个拦截器,继承HandlerInterceptor然后重写里面的preHandle方法即可。逻辑就是判断当前访问的路径是否在我们拦截的接口里面存在,如果不存在就直接放行,如果存在就执行校验当前的用户有没有权限访问这个接口,通过shiro的subject.isPermitted(method + “:” + url)校验当前用户访问的路径是否在权限范围存在。前面说我们是集成了keyclok的所以加了一个逻辑判断,如果当前登录的用户的请求头的token解析之后里面的cookieMap存在键为keycloakToken的数据,代表着这个用户是keycloak登录的,因为在一开始我们就已经做了根据用户登录的方式,如果是keycloak登录那么就会在返回给前端的token里面多加一个键为keycloakToken的数据。所以现在在拦截做校验的时候根据这个key的值是否为空,如果为空就继续以前的本地校验逻辑判断,如果不为空,那么就获取keycloakToken里面的值,获取存在里面的access_token,然后拿这个access_token去keycloak服务器获取这个access_token的用户信息,如果获取不到,代表这个token有问题,跳转到登录页面重新登录。这个是额外的keycloak登录方式才做的校验,其它的校验不管是本地登录还是keycloak登录都需要做的,比如获取请求头里面的access_toke request.getHeader(“access_token”),判断获取的access_toke 是否为空,最重要的是不为空然后解析这个token,校验这个token的真实性以及是否已过期。

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        sysLog = null;
        // 没有加权限验证的注解:放行
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        String method = request.getMethod().toLowerCase();
        String url = request.getRequestURI();
        if (url.equals("/error") || url.equals("/sysRedirect")) return true;
        SysMenu sysMenu = SysInterceptor.idSysMenuMap.get(url);//容器初始化的时候,准备好了所有路径和这个路径的对象信息
        if (sysMenu == null) return true;//如果找不到这个路径,代表这个路径没有进行拦截
        sysLog = getSysLog(request, sysMenu.getType(), sysMenu.getName());//创建这次请求的对象,为了保存访问记录
        Subject subject = ShiroUserUtils.getSubjct(); //创建subject实例
        // sso 校验 start
        String accessToken = "";
        if( request.getHeader("access_token") !=null )
            accessToken = request.getHeader("access_token").toString();
        if( StringUtils.isEmpty(accessToken) ){
            Cookie[] cookies = request.getCookies();
            for( Cookie cookie : cookies ){//再到cookies里面获取accessToken
                if( cookie.getName().equals("access_token") )
                    accessToken = cookie.getValue();
            }
            if( StringUtils.isEmpty(accessToken) )//如果accessToken为空报错
                throw new SysException(SysExceptionCode.CODE_40017);
        }
        String decryptJsonString = AesCBCUtil.decrypt(AesCBCUtil.decrypt(accessToken));//解密token
        Map<String,String> cookieMap = JSONObject.parseObject(decryptJsonString,Map.class);//获取解密的token里面数据
        if( cookieMap == null )
            throw new SysException(SysExceptionCode.CODE_40017);

        String keycloakToken = cookieMap.get("keycloakToken");//获取数据里面的的keycloakToken
        if( keycloakToken != null ){
            // 只有 keycloak 登录会进
            // 通过 restful api 感知 user 的 sso 登出
            CustomUserInfo userInfo = keycloakService.getUserInfo(keycloakToken);//再从keycloakToken里面获取用户对象信息
            if( userInfo == null )
                throw new SysException(SysExceptionCode.CODE_40017);
        }
        //sso end
        SysUser user = ShiroUserUtils.getUser();
        if (user == null) {//判断是否为空
            throw new SysException(SysExceptionCode.CODE_40017);
        } else {//如果不为空再判断是否有这个访问的请求路径权限 subject.isPermitted()shirl的一个方法,判断是否有当前访问这个接口路径的权限
            if (subject.isPermitted(method + ":" + url) /*|| (url2 != null && subject.isPermitted(method + ":" + url2))*/) {
                return true;
            } else {//没有权限则报错
                throw new SysException(SysExceptionCode.CODE_40013);
            }
        }
    }

退出登录,清除KeyCloak会话
点击退出登录时候访问这个接口,后端调用request.logout()去KeyCloak服务器清空当前这个用户的会话,然后重新重定向到登录接口。

@GetMapping("/api/v2/sysLogout")
    public Object logout(String subUrl, HttpServletRequest request) throws ServletException {
        request.logout();
        Subject currentUser = SecurityUtils.getSubject();
        currentUser.logout();
        return "redirect:/api/login"+(StringUtils.isNotEmpty(subUrl)?"?subUrl="+subUrl:"");
    }
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐