基于登录中心的跨域SSO实现

最近在思考单点登录的需求。SSO单点登录能实现多项目间共用登录信息,控制用户的角色信息。有统一的登录门户就不用每个项目维护登录功能了,比较方便。
记录一下Springboot实现的宝宝demo,代码分为中心和client两部分,已上传至github。
https://github.com/huiluczP/sso_register
https://github.com/huiluczP/sso_client

跨域单点登录原理

总的来说分为两个部分的实现,SSO登陆中心和client项目的登录信息获取。
利用redis进行token和可暴露的用户信息的存储,因为是demo就直接将用户名设置为token的key,value为可暴露用户信息的json字符串。

SSO登陆中心功能:

  1. 登录页面显示
  2. 登录成功的判断
  3. 登陆成功后的页面跳转
  4. Token的redis存储
  5. Token的有效性判断
  6. 登出时的token处理

Client:

  1. 拦截器实现身份验证
  2. 访问SSO中心验证接口,登录页面
  3. 从SSO中心获取非敏感身份信息,session中存放
  4. 登出功能,访问SSO中心登出接口

给出登录情况下的时序图,主要思路为:
Client设置拦截器,进行session和cookie的查询,没有cookie则跳转到SSO中心进行登录并获取token,传回后存放cookie并跳转原站点。原站点Client此时拦截器获取到cookie信息,访问SSO端口进行验证,验证返回可暴露的用户信息。Client获取到可暴露信息,设置session信息供自己站点后续使用。
基于登录中心的跨域SSO实现_第1张图片
登出时序图,主要思路为:
Client页面给出登出按钮,删除session中信息,并将cookie中token信息发送到SSO中心进行redis中token删除,之后跳转回login或者client的默认页面。
基于登录中心的跨域SSO实现_第2张图片
特别的,为了解决浏览器cookie跨域问题,对跨域的请求我利用RestTemplate在后端进行处理。

SSO登陆中心

登录中心后端的实现只要包括几个部分:redis处理相关类,数据库相关类和登录,登出,验证接口的逻辑实现。

redis处理相关类

RedisConfig redisTemplate设置类

因为RedisTemplate默认的序列化比较慢且没有清晰的结构,利用jackson将其默认序列化设置为jackson的序列器(虽然整个项目好像都没用到…)。

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory redisConnectionFactory) {
        // jackson序列化设置
        GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        // template初始化
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        // key序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        // value序列化
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        // Hash key序列化
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        // Hash value序列化
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }
}
RedisUtil redis操作工具类

包括基本的redis操作,token操作相关主要是带超时时间的add,超时时间的更新,value获取和key的删除。

@Slf4j
@Component
public class RedisUtil {
    @Autowired
    private StringRedisTemplate redisTemplate;

private final String DEFAULT_KEY_PREFIX = "";

    public <K, V> void add(K key, V value, long timeout, TimeUnit unit) {
        try {
            if (value != null) {
                redisTemplate
                        .opsForValue()
                        .set(DEFAULT_KEY_PREFIX + key, JSON.toJSONString(value), timeout, unit);
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException("数据缓存至redis失败");
        }
    }

    public <K> String get(K key) {
        String value;
        try {
            value = redisTemplate.opsForValue().get(DEFAULT_KEY_PREFIX + key);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException("从redis缓存中获取缓存数据失败");
        }
        return value;
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    public Boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }

    public Boolean expire(String key, long timeout, TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }
RedisService Token操作逻辑类

对token进行操作,token.over-time在application设置文件中进行设置

@Service
public class RedisService {
    @Value("${token.over-time}")
    private long tokenOverTime;

    @Autowired
    RedisUtil redisUtil;

    @Autowired
    UserService userService;

添加token缓存,当token已存在,说明又调用了登陆接口,更新过期时间。

    public String addToken(String token) throws JsonProcessingException {
        if(redisUtil.hasKey(token)){
            // 更新过期时间
            redisUtil.expire(token, tokenOverTime, TimeUnit.SECONDS);
        }else{
            User user = userService.getUser(token);
            ObjectMapper mapper = new ObjectMapper();
            String userJson = mapper.writeValueAsString(user);
            redisUtil.add(token, userJson, tokenOverTime, TimeUnit.SECONDS);
            System.out.println("add token:" + token);
        }
        return String.valueOf(redisUtil.getExpire(token, TimeUnit.SECONDS));
    }

判断缓存中是否出现token,获取对应信息。

    // 查找token是否存在
    public boolean isTokenExist(String token){
        return redisUtil.get(token) != null;
    }

    // 删除token
    public void deleteToken(String token){
        System.out.println("token: " + token + " 正在删除");
        if(isTokenExist(token))
            redisUtil.delete(token);
    }

    public String getValue(String token){
        return redisUtil.get(token);
    }
}

数据库处理相关类

简单利用Mybatis进行user信息的存取。

User实体类

有个用户身份字段。要注意的是password为敏感信息,转换成json时被ignore。(@JsonIgnore注解)

public class User implements Serializable {
    private static final long serialVersionUID = 356425130811357425L;

    public User(){
    }

    public User(String userName, String password, String role){
        this.userName = userName;
        this.password = password;
        this.role = role;
    }

    private int id;
    private String userName;
    @JsonIgnore
    private String password;
	private String role;
Mapper类

用户信息获取方法。

@Mapper
public interface UserMapper {
    @Select("select id, username as userName, password, role " +
            "from user where username = #{userName}")
public User getUserByName(String userName);
}
UserService用户信息逻辑类

主要是对用户登录的验证和用户信息的获取,涉及数据库的操作。

@Service
public class UserService {
    private final UserMapper userMapper;

    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    // 验证用户
    public boolean checkUser(String userName, String password){
        User user = userMapper.getUserByName(userName);
        if(user!=null){
            System.out.println(user.getPassword());
            System.out.println(password);
            return user.getPassword().equals(password);
        }else {
            return false;
        }
    }

    // 获取用户信息
    public User getUser(String userName){
        return userMapper.getUserByName(userName);
    }
}

登录,验证,登出接口的实现

集中在ssoLoginController类中。

CommonResponse 通用返回类

包括成功与否和主要信息。

public class CommonResponse {
    private boolean success;
    private String message;
    public CommonResponse(){

    }
    public CommonResponse(boolean success, String message){
        this.success = success;
        this.message = message;
	}
}
登录功能接口

登陆页面的访问。

    @RequestMapping("/user/login")
    // 统一的登录页面
    public String loginPage(){
        return "/login.html";
    }

登录接口的实现,其中url为访问sso登陆页面之前的访问地址,方便登陆后的跳转。获取的token存到cookie中,设置超时时间,防止浏览器关闭后cookie被清除。因为前端是在用ajax进行接口调用,不在后端redirect,将url返还给前端进行跳转。

    @RequestMapping("/login")
    @ResponseBody
    // 验证登录,数据库对接
    // 成功后redis增加token缓存,token就简单用username即可
    public CommonResponse login(HttpServletResponse response, String userName, String password, String url) throws JsonProcessingException {
        if(userService.checkUser(userName, password)){
            // token生成并redirect到原始url
            String time = redisService.addToken(userName);
            System.out.println("redis已成功添加token 时效:" + time);
            Cookie cookie = new Cookie("accessToken", userName);
            //设置访问路径
            cookie.setPath("/");
			// 设置1小时先
            cookie.setMaxAge(60*60);
            response.addCookie(cookie);
            return new CommonResponse(true, url);
        }else{
            return new CommonResponse(false, "用户名或密码错误");
        }
    }
验证功能接口

验证功能,方便client验证cookie中的token信息,同时返回redis缓存中的user可暴露信息。

    @RequestMapping("/validate")
    @ResponseBody
    // 验证token的有效性
    public CommonResponse validate(String token) throws JsonProcessingException {
        if(redisService.isTokenExist(token)){
            // 解析token,获取对应可暴露信息
            String userJson = redisService.getValue(token);
            return new CommonResponse(true, userJson);
        }else {
            System.out.println("get token:" + token + " 失败");
            return new CommonResponse(false, "false");
        }
    }
登出功能接口

主要就是删除下token缓存。

    @RequestMapping("/logout")
    @ResponseBody
    // 验证token的有效性
    public CommonResponse logout(String token) throws JsonProcessingException {
        redisService.deleteToken(token);
        System.out.println("token: " + token + " 删除成功");
        return new CommonResponse(true, "登出成功");
    }

login前端实现

DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>logintitle>
    <script src="http://apps.bdimg.com/libs/jquery/2.1.1/jquery.min.js">script>
    <script src="/js/login.js">script>
head>
<body>
    <h3>统一登录页面h3>
    <label for="username">用户名:label><input type="text" id="username"/>
    <label for="password">密码:label><input type="password" id="password"/>
    <input type="button" value="submit" id="submit"/>
body>
html>

Login的js实现。其中需要注意的是url为redirect的参数,如果为false,表示之前没有跳转直接就在sso中心登录,跳转到成功页面。Ajax进行登录验证,返回后续url进行跳转。

$(function(){
    $('#submit').bind('click', checkLogin)
})

function checkLogin(){
    var user = $('#username').val()
    var password = $('#password').val()
    var url = getUrlParams("redirect")
    console.log(user)
    console.log(password)
    console.log(url)
    if(url==false)
        url = '/user/success'

    $.ajax({
        url:"/login",
        type:"post",
        cache: false,
        data: {
            'userName': user,
            'password': password,
            'url': url
        },
        dataType: 'json',
        success:function(data){
            var success = data.success
            var message = data.message
            if(success){
                console.log(url)
                console.log(message)
                window.location.href = url
            }else{
                console.log(message)
            }
        },
        error:function(){
            console.log("登录信息错误")
        }
    })
}

function getUrlParams(key) {
	var url = window.location.search.substring(1);
	if (url == '') {
		return false;
	}
	var paramsArr = url.split('&');
	for (var i = 0; i < paramsArr.length; i++) {
		var combina = paramsArr[i].split("=");
		if (combina[0] == key) {
			return combina[1];
		}
	}
	return false;
}

Client配置

虽然我没有模块化啊,但基本上解耦了,登录的所有逻辑都可以用一个拦截器类进行分装,只要在配置文件中将SSO中心的站点和接口地址进行配置,并将拦截器类导入即可实现单点登录。

配置文件相关

包括SSO站点域名,登陆页面地址,验证和登出接口的配置。

sso:
  prefix: http://localhost:8080
  login-page: /user/login
  validate: /validate
  logout: /logout

CookieInterceptor拦截器实现

拦截器主要为了在访问client相关站点或接口时进行身份的验证。基本逻辑为:当session中存在身份信息,即放行;不存在且Cookie中存在token信息,访问SSO验证端口进行token验证;不存在对应Cookie,则直接跳转登录页面。
其中ssoService包含一些跳转和接口访问逻辑,如果要模块化的话,可以将其设置为内部类,方便打包。

@Component
public class CookieInterceptor implements HandlerInterceptor {

    @Autowired
    SSOService ssoService;

    // 如果有session,那么直接放行
    // 如果有cookie,就转发给sso进行验证,验证后通过后给予session
    // 没有cookie或验证不通过,跳转login页面
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session=request.getSession();
        Object role = session.getAttribute("role");
        Object user = session.getAttribute("user");
        if(role!=null&&user!=null){
            return true;
        }else{
            Cookie[] cookies=request.getCookies();
            boolean isOk = false;
            Cookie cookieNow = null;
            if(cookies!=null){
                for(Cookie cookie:cookies) {
                    if (cookie.getName().equals("accessToken")) {
                        cookieNow = cookie;
                        System.out.println("获得携带access token的cookie: " + cookie.getValue());
                        isOk = true;
                        break;
                    }
                }
            }
            if(isOk){
                // 进行验证
                ssoService.validateTokenFromSSO(request, response, cookieNow.getValue());
            }else{
                // 跳转到统一登陆页面
                System.out.println("不存在cookie信息");
                ssoService.redirectToLoginPage(request, response);
            }
        }
        return true;
    }
}

SSOService SSO访问逻辑实现

首先从配置文件中读取对应的接口和页面地址。后端我使用了RestTemplate进行端口访问,实现初始化RestTemplate对象的方法。

@Service
public class SSOService {

    @Value("${sso.login-page}")
    private String urlLoginPage;

    @Value("${sso.validate}")
    private String urlValidate;

    @Value("${sso.prefix}")
    private String prefix;

    @Value("${sso.logout}")
    private String urlLogout;

    public RestTemplate restTemplate(ClientHttpRequestFactory factory) {
        return new RestTemplate(factory);
    }

    public ClientHttpRequestFactory simpleClientHttpRequestFactory() {
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setReadTimeout(5000);
        factory.setConnectTimeout(15000);
        // 设置代理
        //factory.setProxy(null);
        return factory;
    }

跳转login页面方法,设置为跨域方法。需要注意的是,需要将跳转地址后加上当前访问地址作为redirect信息。

    // 跳转到login页面
    // 将原始页面添加到末尾
    @CrossOrigin
    public void redirectToLoginPage(HttpServletRequest request, HttpServletResponse response) throws IOException {
        System.out.println("跳转登陆页面");
        String formerUrl = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getRequestURI();
        System.out.println(prefix + urlLoginPage + "?redirect=" + formerUrl);
        response.sendRedirect(prefix + urlLoginPage + "?redirect=" + formerUrl);
}

在session中设置身份信息

    public void addSessionInfo(HttpServletRequest request, String userName, String role){
        HttpSession session = request.getSession();
        session.setAttribute("role", role);
        session.setAttribute("user", userName);
    }

利用RestTemplate进行验证接口的访问,成功则返回user可暴露信息,并进行session设置。失败则跳转到sso中心的登陆页面。

    // 通过外部接口获取token可行性
    // 可行直接增加session
    // 不存在则进行跳转
    public void validateTokenFromSSO(HttpServletRequest request, HttpServletResponse response, String token) throws IOException {
        UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(prefix).
                path(urlValidate).build(true);
        URI url = uriComponents.toUri();

        RestTemplate restTemplate = restTemplate(simpleClientHttpRequestFactory());

        MultiValueMap<String, Object> params = new LinkedMultiValueMap<>(2);
        params.add("token", token);
        System.out.println("token " + token + " 已注入");

        ResponseEntity<JSONObject> responseEntity = restTemplate.postForEntity(url, params, JSONObject.class);
        JSONObject body = responseEntity.getBody();

        int statusCodeValue = responseEntity.getStatusCodeValue();
        if(statusCodeValue!=200||body==null){
            System.out.println("验证接口连接失败");
        }else{
            if((boolean)body.get("success")){
                String userJson = (String) body.get("message");

                System.out.println("验证成功:" + userJson);
                userJson = StringEscapeUtils.unescapeJava(userJson);
                System.out.println(userJson.substring(1, userJson.length()-1));

                JSONObject json = JSONObject.parseObject(userJson.substring(1, userJson.length()-1));
                String userName = json.getString("userName");
                String role = json.getString("role");
                addSessionInfo(request, userName, role);
            }else{
                System.out.println(statusCodeValue);
                String userJson = (String) body.get("message");
                System.out.println(userJson);
                redirectToLoginPage(request, response);
            }
        }
    }

登出方法,对SSO登出接口进行访问。

    public void logOutFromSSO(HttpServletRequest request, HttpServletResponse response, String token){
        // 链接sso中心的logout方法,把token删除
        UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(prefix).
                path(urlLogout).build(true);
        URI url = uriComponents.toUri();

        RestTemplate restTemplate = restTemplate(simpleClientHttpRequestFactory());

        MultiValueMap<String, Object> params = new LinkedMultiValueMap<>(2);
        params.add("token", token);
        System.out.println("token " + token + " 已注入");

        ResponseEntity<JSONObject> responseEntity = restTemplate.postForEntity(url, params, JSONObject.class);
        JSONObject body = responseEntity.getBody();

        int statusCodeValue = responseEntity.getStatusCodeValue();
        if(statusCodeValue!=200||body==null){
            System.out.println("验证接口连接失败");
        }else{
            System.out.println(statusCodeValue);
            String message = (String) body.get("message");
            System.out.println(message);
        }
    }

MainController Client简单信息展示实现

从session中获取user信息。

    // main页面
    @RequestMapping("/main")
    public String mainPage(){
        return "/main.html";
    }

    // 获取信息
    @RequestMapping("/userInfo")
    @ResponseBody
    public String getUserSessionInfo(HttpServletRequest request){
        JSONObject jsonObject = new JSONObject();
        String user = (String)request.getSession().getAttribute("user");
        String role = (String)request.getSession().getAttribute("role");
        jsonObject.put("user", user);
        jsonObject.put("role", role);
        return jsonObject.toJSONString();
    }

登出功能,删除session中user信息,存在cookie的话就进行SSO中心的访问,将token传过去从缓存中删除。

    @RequestMapping("/logout")
    @ResponseBody
    public String logOut(HttpServletRequest request, HttpServletResponse response){
        request.getSession().setAttribute("user", null);
        // cookie中获取token
        Cookie[] cookies=request.getCookies();
        boolean isOk = false;
        Cookie cookieNow = null;
        if(cookies!=null){
            for(Cookie cookie:cookies) {
                if (cookie.getName().equals("accessToken")) {
                    cookieNow = cookie;
                    System.out.println("获得携带access token的cookie: " + cookie.getValue());
                    isOk = true;
                    break;
                }
            }
        }
        if(isOk) {
            ssoService.logOutFromSSO(request, response, cookieNow.getValue());
        }
        // 没有cookie,直接返回就好了
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("message", "/main");
        return jsonObject.toJSONString();
    }

main.html 前端实现

就是简单展示下用户信息,看看成没成。

DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>maintitle>
    <script src="http://apps.bdimg.com/libs/jquery/2.1.1/jquery.min.js">script>
    <script src="main.js">script>
head>
<body>
    <h3>welcome:h3>
    <h3 id="username">?h3>
    <h3 id="role">?h3>
    <input type="submit" value="登出" id="logout"/>
body>
html>

main.js
两个接口的ajax访问和对应信息展示。

$(function(){
    getInfo()
    $('#logout').bind('click', logOut)
})

function getInfo(){
    // ajax请求找下数据库中该文件是否存在
    $.ajax({
        url:"/userInfo",
        type:"post",
        cache: false,
        data: {
        },
        dataType: 'json',
        success:function(data){
            var user = data.user
            var role = data.role
            $('#username').html(user)
            $('#role').html(role)
        },
        error:function(){
            console.log("user信息获取错误")
        }
    })
}

function logOut(){
    $.ajax({
        url:"/logout",
        type:"post",
        cache: false,
        data: {
        },
        dataType: 'json',
        success:function(data){
            var url = data.message
            console.log(url)
            window.location.href = url
        },
        error:function(){
            console.log("user信息获取错误")
        }
    })
}

效果展示

http://localhost:8081/main 为client site
http://localhost:8080 为SSO站点
登录:
基于登录中心的跨域SSO实现_第3张图片
登出:
基于登录中心的跨域SSO实现_第4张图片

可以看出,登录成功保存token,并获取了session中信息。通过验证即可保持登陆状态。
登出时,清除了session信息,token从缓存中被删除,只能重新登录。

总结

这篇简单整理了基于登陆中心的SSO单点登陆方法,客户端模块化的话,只要额外的配置文件和拦截器设置即可。
真实业务里如果token被获取可能会有csrf攻击等问题,考虑csrf伪token,设置cookie域(项目里直接设置成了“/”)之类的方法进行防御。总的来说就是个简单demo尝试,感兴趣的看看吧。

你可能感兴趣的:(java,spring,java,springboot,restful)