基于Springboot的SSO单点登陆系统的登陆操作实战

一 前言

(1)使用环境:

SpringBoot2.X

MyBatis

基于redis存储的springSession

(2)基础学习:

关于SSO的基础学习可以参考该文章:单点登录(SSO),从原理到实现

代码风格使用的是晓风轻的代码规范,对于其中的AOP实现此处不会给出代码,具体可以在文章尾部的gitHub上查看:我的编码习惯 - Controller规范

进阶可以参考:单点登录(一)-----理论-----单点登录SSO的介绍和CAS+选型

(3)目标

  1. 使用Header认证替换Cookie,避免用户禁用cookie导致登陆失效的情况
  2. 实现可以运行操作的SSO单点登录系统

(4)注意:

  1. 此处使用了一个项目来模拟一个Client与一个Server,因为Server依靠存储token来判断用户是否登陆,而Client依靠Session判断用户是否登陆,因此两者能在同个项目共存。
  2. 由于项目的依赖很多,所以不会事无巨细地讲,只会挑重点的看,具体的可以在文章尾部的GitHub上查看

看完以上文章之后总结一下,在这次简单实现中我们需要做到的有以下几点:

  1. Client服务端收到请求,Filter拦截该请求,在Filter中判断该用户是否已经登陆,如果已经登陆,就直接进入系统,否则,返回用户没有登陆的信息,由前端页面进行跳转到SSO服务器的登录页面,此时要带上原页面的url,下面成为clientUrl。
  2. 在LoginURL中会获取到用户的token,检验用户是否已经在其他相关使用SSO的系统登陆成功。如果已经在其他的系统登陆了,则将请求转回Client,并且带回一个token, Client再次发送请求到ValidateURL。否则,系统提示用户输入ID和PASSWORD。
  3. 提交后请求到ValidateURL,Server验证token的有效性。然后返回结果给Client。如果token有效,则Client与用户之间建立局部会话。否则,重定向到登陆页面,提示用户输入ID和PASSWORD。
  4. 校验ID和Password是否匹配。如不匹配,再次要求用户输入ID和PASSWORD。否则,Server记录用户登陆成功。并向浏览器回送token,记录用户已经登陆成功。
    那么马上开始SSO单点登陆之旅

二 基础工具类

首先来看下基础工具类,比较多,不想看的朋友可以跳到下一大节,下面看到有不清楚的方法可以搜索回来这里看。以下在gitHub上都有:

配置redis:

@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
    @Bean
    <T> RedisTemplate<String, T> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, T> redisTemplate = new RedisTemplate<>();
        System.out.println("加载redis配置");

        Jackson2JsonRedisSerializer j = new Jackson2JsonRedisSerializer(Object.class);
        // value值得序列化采用fastJsonRedisSerializer
        redisTemplate.setValueSerializer(j);
        redisTemplate.setHashValueSerializer(j);
        // key的序列化采用StringRedisSerializer
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.setEnableTransactionSupport(true);
        return redisTemplate;
    }
}

使用SpringSession替代Tomcat内置的Session,并且设置为Header认证:

@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1801)
public class HttpSessionConfig {
    @Bean
    public HeaderHttpSessionIdResolver httpSessionStrategy() {
        return new HeaderHttpSessionIdResolver("x-auth-token");
    }
}

此处是一个HttpClient类,使用其架起两个服务器之间的通信:

@Service("httpClientUtil")
@Slf4j
public class HttpClientUtil {

    /**
     * 使用Json格式发送Post请求
     *
     * @param url         发送的URL
     * @param requestData 传给数据
     * @return
     * @throws IOException
     */
    public ResultBean<Data> postAction(String url, RequestBean requestData) throws IOException {
        // 将Json对象转换为字符串
        Gson gson = new Gson();
        String strJson = gson.toJson(requestData, requestData.getClass());
        log.info("httpClient发送数据:{}", strJson);
        //使用帮助类HttpClients创建CloseableHttpClient对象.
        CloseableHttpClient client = HttpClients.createDefault();
        //HTTP请求类型创建HttpPost实例
        HttpPost httpPost = new HttpPost(url);

        httpPost.setHeader("Content-Type", "application/json;charset=UTF-8");

        //组织数据
        StringEntity se = new StringEntity(strJson);
        se.setContentType("application/json");

        //对于httpPost请求,把请求体填充进HttpPost实体.
        httpPost.setEntity(se);

        CloseableHttpResponse response = null;
        try {
            // 执行请求
            response = client.execute(httpPost);
            HttpEntity entity = response.getEntity();
            // 判断返回状态是否为200
            if (response.getStatusLine().getStatusCode() == 200) {
                strJson = EntityUtils.toString(entity, "UTF-8").trim();
                Type type = new TypeToken<ResultBean<Data>>() {
                }.getType();
                ResultBean<Data> resultBean = VerifyUtil.cast(gson.fromJson(strJson, type));

                // 处理子系统局部会话的Header认证
                if (null != response.getFirstHeader("x-auth-token")) {
                    if (null == resultBean.getData()) {
                        resultBean.setData(new Data());
                    }
                    resultBean.getData().setAuthToken((response.getFirstHeader("x-auth-token").toString()).split(" ")[1]);
                }
                return resultBean;
            }
            return null;
        } finally {
            if (response != null) {
                response.close();
            }
            client.close();
        }
    }
}

一个会经常用到的工具类:

public class VerifyUtil implements Serializable {
	/**
     * 字符串判空
     * @param str 字符串
     * @return 不为空返回true
     */
    public static boolean isNotEmpty(String str) {
        return (null != str && !str.equals(""));
    }

    /**
     * 字符串判空
     * @param args 多个字符串
     * @return 全部不为空返回true
     */
    public static boolean isNotEmpty(String... args) {
        for (String str : args) {
            if (null == str || str.equals("")) {
                return false;
            }
        }
        return true;
    }

    /**
     * 检查字符串是否为null
     * @param arg 多个字符串
     * @return 全部不为null返回true
     */
    public static boolean checkNull(String...arg) {
        for (String str : arg) {
            if (!checkNull(str)) {
                return false;
            }
        }
        return true;
    }

    /**
     * 对象判空
     * @param object 对象
     * @return 不为空返回true
     */
    public static boolean checkNull(Object object) {
        return null != object;
    }

    @SuppressWarnings("unchecked")
    public static <T> T cast(Object object) {
        return (T)object;
    }
}

一个用户状态的枚举类:

@AllArgsConstructor
public enum UserStatusEnum {

    PARAMETER_ERROR("parameter_error"),

    USER_ACCOUNT_ERROR("user_account_error"),

    USER_HAS_REGISTER("user_has_register"),

    URL_OR_TOKEN_ERROR("url or token error"),

    USER_HAS_NOT_LOGIN("you has not login")
    ;

    private String msg;

    public String getMsg() {
        return msg;
    }
}

系统之间进行交互的实体类:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class RequestBean {
    User user;

    String token;

    String clientUrl;

    String authToken;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResultBean<T> implements Serializable {

    private static final long serialVersionUID = 1L;

    public static final int NO_LOGIN = -1;

    public static final int SUCCESS = 0;

    public static final int FAIL = 1;

    public static final int NO_PERMISSION = 2;

    private String msg = "success";

    private int code = SUCCESS;

    private T data;

    public ResultBean() {
        super();
    }

    public ResultBean(T data) {
        super();
        this.data = data;
    }

    public ResultBean(Throwable e) {
        super();
        this.msg = e.toString();
        this.code = FAIL;
    }
}
@lombok.Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class Data {
    User user;

    /**
     * 单点登录令牌
     */
    String token;

    String clientUrl;

    String authToken;
}

三 正文

那么现在就进入正文了,整个项目还是采用了MVC的架构,下面我们将主要看下controller层与service层:

首先是controller层:

@CrossOrigin
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    UserService userService;

    // sso服务端
    /**
     * 用户登陆
     * @param requestBean user、clientUrl、token
     * @return token、clientUrl、authToken
     */
    @PostMapping("/login")
    public ResultBean<Data> login(@RequestBody RequestBean requestBean) {
        return new ResultBean<>(userService.login(requestBean));
    }

    /**
     * 验证客户端的token与clientUrl是否合法,若合法则将客户端的clientUrl注册到token中
     * @param requestBean token、clientUrl
     * @return 操作结果,成功data为null
     */
    @PostMapping("/valid")
    public ResultBean<Data> validToken(@RequestBody RequestBean requestBean) {
        return new ResultBean<>(userService.valid(requestBean));
    }

    // sso客户端
    
    /**
     * 接收来自服务器的token与clientUrl,
     * @param httpSession 操作session
     * @param requestBean token、clientUrl
     * @return 操作结果,成功data为带token与clientUrl
     */
    @PostMapping("/token")
    public ResultBean<Data> token(HttpSession httpSession, @RequestBody RequestBean requestBean) {
        ResultBean<Data> resultBean = new ResultBean<>(userService.token(requestBean));
        // 验证成功
        if (resultBean.getCode() == 0) {
//             此处仅仅设置用户会话,用户信息的获取在其他请求处理
            System.out.println("设置session");
            httpSession.setAttribute("user", new User());
        }
        return resultBean;
    }
}

一共只有三个方法,下面说下三者各自的任务:

在login()中对用户的登录状态进行判断,验证用户的token,若用户仍未登录则提示用户登录。若用户处于登录状态(无论是以注册token还是刚使用ID与PASSWORD登录),则利用请求中的clientUrl + "/user/token"将token传输给Client(子服务器)。注意,在login()中并没有将clientUrl注册进token内,clientUrl需要经过Client验证后方才可以注册进token。

在token()中则是Client接收SSO Server传输过来的token数据,并且将其发送给serverUrl + "/user/valid"进行验证,若验证通过,则为用户设置user的Session属性,并且将该session对应的x-auth-token传递回去给SSO Server。

valid()中验证并将clientUrl注册进token中。

下面让我们来继续深入service层:

public interface UserService {
    /**
     * 用户登陆
     * @param requestBean user、clientUrl、token
     * @return token、clientUrl、authToken
     */
    Data login(RequestBean requestBean);

    /**
     * 验证客户端的token与clientUrl是否合法,若合法则将客户端的clientUrl注册到token中
     * @param requestBean token、clientUrl
     * @return 操作结果,成功data为null
     */
    Data valid(RequestBean requestBean);

    /**
     * 接收来自服务器的token与clientUrl,
     * @param requestBean token、clientUrl
     * @return 操作结果,成功data为带token与clientUrl
     */
    Data token(RequestBean requestBean);
}

具体比较重要的实现类,这里我将按照前面所说的SSO流程来进行说明,即是按照一个请求从头走到尾的方式。同样,需要完整代码的看文末的github:

首先是基础内容:

@Service
@Slf4j
public class UserServiceImpl implements UserService {

    /**
     * 用户的Mysql数据库操作
     */
    @Resource
    UserDao userDao;

    /**
     * 服务器之间的通讯方式
     */
    @Resource
    HttpClientUtil httpClientUtil;

    /**
     * redis数据库操作
     */
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 建立token与clientUrl的映射,系统注销的时候会用到
     */
    private static Map<String, List<String>> tokenAndUrlMap = new HashMap<>();

    /**
     * 建立token与user信息的映射,子系统请求用户信息的时候回用到
     */
    private static Map<String, User> tokenAndUserMap = new HashMap<>();

    /**
     * 建立token与sessionID,即x-auth-token之间的映射,用户请求x-auth-token的时候会用到
     */
    private static Map<String, String> tokenAndSessionId = new HashMap<>();
}

首先从登陆开始:

/**
 * 验证用户是否已登录、验证请求参数是否合法
 * @param requestBean user、clientUrl、token
 * @return token、clientUrl、authToken
 */
@Override
public Data login(RequestBean requestBean) {
    String token = requestBean.getToken();
    String clientUrl = requestBean.getClientUrl();
    log.info("login() : token = {}, clientUrl = {}", token, clientUrl);

    // 用户使用token、clientUrl代表需要查询是否已登录
    // tokenAndUrlMap中包含key代表已登录
    if (isNotEmpty(token, clientUrl) && tokenAndUrlMap.containsKey(token)) {
        log.info("token = {} 用户已登陆", token);
        // 将信息反馈给Client
        Data data = transmitToken(token, clientUrl);
        tokenAndSessionId.put(token, data.getAuthToken());
        return data;
    }

    // 使用账号密码进行登陆

    User user = requestBean.getUser();

    // 登陆信息为空,说明用户意图不为登陆,抛出未登录状态的异常
    if (!checkNull(user)) {
        throw new UnloginException(UserStatusEnum.USER_HAS_NOT_LOGIN.getMsg());
    }

    // 检验合法参数,并进入登陆逻辑
    if (isNotEmpty(user.getAccount(), user.getPassword(), clientUrl)) {
        return loginImpl(user, clientUrl);
    }

    // 说明参数检验不通过,抛出参数错误异常
    throw new CheckException(UserStatusEnum.PARAMETER_ERROR.getMsg());
}

说下我这里的实现都是使用X()先对参数进行一些基本的验证,验证不通过则直接抛出异常,否则进入XImpl()进行逻辑处理。

	/**
     * 用户登录逻辑实现
     * @param user 用户信息:account、password
     * @param clientUrl Client的url
     * @return token、clientUrl、authToken
     */
    private Data loginImpl(User user, String clientUrl) {
        String account = user.getAccount();
        String password = user.getPassword();

        // 查询数据库,若无此用户数据则抛出账号错误异常
        user = userDao.listUserByUAccountAndPassword(account, password);
        if (!checkNull(user)) {
            throw new CheckException(UserStatusEnum.USER_ACCOUNT_ERROR.getMsg());
        }

        // 使用uuid生成token并存入tokenMap中,注意此时并没有注册clientUrl
        String token = UUID.randomUUID().toString();
        tokenAndUrlMap.put(token, new ArrayList<>());
        tokenAndUserMap.put(token, user);

        // 将信息反馈给Client、
        Data data = transmitToken(token, clientUrl);
        tokenAndSessionId.put(token, data.getAuthToken());

        log.info("loginImpl() 登录成功:token = {}, clientUrl = {}, sesssionId = {}, User = {}", token, clientUrl, tokenAndSessionId.get(token), user);
        return data;
    }

此处给出login()的数据格式:

{
	"user":{
		"account":"1",
		"password":"1"
	},
	"clientUrl":"http://localhost:8889",
	"token":"b8b51c17-dcb8-414f-af62-e2d1f3f49037"
}

操作流程:

  1. 当用户携带token与clientUrl时,需要查询相应的token是否已登录,若已登录则将信息反馈给子系统。
  2. 当用户没有携带token或者token未登录时,开始检验用户登录的账号密码参数,检验成功则查询数据库是否存在符合条件的用户,若符合条件则将信息反馈给子系统

不过大家应该发现了transmitToken(token, clientUrl)这个方法,可以猜测到他实现的功能应该是利用clientUrl将token传递给子系统,并且带回authToken,即x-auth-token。相当于用户在子系统中局部会话的JESSIONID。

	/**
     * 用以将生成的token或者以验证登陆的token传递回去给Client
     *
     * @param token     令牌
     * @param clientUrl Client的url
     */
    private Data transmitToken(String token, String clientUrl) {
        try {
            return (httpClientUtil.postAction(clientUrl + "/user/token", new RequestBean().setToken(token).setClientUrl(clientUrl)).getData());
        } catch (IOException e) {
            e.printStackTrace();
            throw new ErrorException("clientUrl error");
        }
    }

这里的方法参考上一大节的HttpClient工具类,操作是把RequestBean当做参数,发送请求到clientUrl + "/user/token"中,并且返回类型为Data的参数。从clientUrl + "/user/token"开始大家应该想到这个是服务器与服务器之间的请求了。那么从controller层进入:

	/**
     * 接收来自服务器的token与clientUrl,
     * @param httpSession 操作session
     * @param requestBean token、clientUrl
     * @return 操作结果,成功data为带token与clientUrl
     */
    // sso客户端
    @PostMapping("/token")
    public ResultBean<Data> token(HttpSession httpSession, @RequestBody RequestBean requestBean) {
        ResultBean<Data> resultBean = new ResultBean<>(userService.token(requestBean));
        // 验证成功
        if (resultBean.getCode() == 0) {
//             此处仅仅设置用户会话,用户信息的获取在其他请求处理
            System.out.println("设置session");
            httpSession.setAttribute("user", new User());
        }
        return resultBean;
    }

从SSO的逻辑中,我们知道当SSO认证中心把token发回给子系统时,子系统需要使用token在SSO认证中心进行注册,在验证注册成功后,设置局部会话,并且需要把设置局部会话(即Session)产生的x-auth-token传递回给SSO认证中心。

那么我们继续看子系统的token方法应该怎样处理此处的逻辑的:

/**
 * 验证来自服务器的token与clientUrl参数合法性,
 * @param requestBean token、clientUrl
 * @return 操作结果,成功data为带token与clientUrl
 */
@Override
public Data token(RequestBean requestBean) {
    String token = requestBean.getToken();
    String clientUrl = requestBean.getClientUrl();
    if (isNotEmpty(token, clientUrl)) {
        return tokenImpl(token, clientUrl);
    }
    throw new CheckException(UserStatusEnum.PARAMETER_ERROR.getMsg());
}

验证参数合法性,合法则进入逻辑处理:

	private Data tokenImpl(String token, String clientUrl) {
        if (remoteValid(token, clientUrl)) {
            return new Data().setToken(token).setClientUrl(clientUrl);
        }
        throw new ErrorException("valid error");
    }
	/**
     * 向SSO发送令牌与本地url,验证注册
     *
     * @param token     令牌
     * @param clientUrl 子系统url
     * @return true 验证成功
     */
    private boolean remoteValid(String token, String clientUrl) {
        try {
            // 0为验证成功
            if ((httpClientUtil.postAction("http://localhost:8889/user/valid", new RequestBean().setToken(token).setClientUrl(clientUrl))).getCode() == 0) {
                return true;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return false;
    }

这里是将SSO认证中心发来的token发回SSO认证中心进行认证注册,我们在上面用户登录的时候并没有对用户的clientUrl进行注册,注册行为在此处才会发生。

这里我们再次跳转到SSO认证中心的valid方法,controller层没有特殊处理,直接看service层:

	/**
     * 检验基本的token、clientUrl参数的合法性
     * @param requestBean token、clientUrl
     * @return 操作结果,成功data为null
     */
    @Override
    public Data valid(RequestBean requestBean) {
        String token = requestBean.getToken();
        String clientUrl = requestBean.getClientUrl();
        if (isNotEmpty(token, clientUrl)) {
            return validImpl(token, clientUrl);
        }

        throw new CheckException(UserStatusEnum.PARAMETER_ERROR.getMsg());
    }
	/**
     * 验证客户端的token与clientUrl是否合法,若合法则将客户端的clientUrl注册到token中
     * @param token  令牌,用户SSO认证中心登录的凭证
     * @param clientUrl 子系统的url
     * @return 操作结果,成功data为null
     */
    private Data validImpl(String token, String clientUrl) {
        boolean hasSave = false;

        // 验证数据是否合法且token是否存在
        if (tokenAndUrlMap.containsKey(token)) {
            List<String> urls = tokenAndUrlMap.get(token);
            // 验证url是否已保存
            if (null != urls) {
                for (String url : urls) {
                    if (url.contains(clientUrl)) {
                        hasSave = true;
                    }
                }
            }

            if (!hasSave) {
                urls.add(clientUrl);
            }
            // 返回null即可以,默认成功
            return null;
        } else {
            throw new ErrorException("has not exist token");
        }
    }

可以看到的是我们在这里才开始进行了token与clientUrl的注册,之所以要加进去是为了以后注销功能的实现。

此处的功能比较简单,对token进行验证,验证通过之后就将其加入到tokenAndurlMap的映射中。

到这里就是登录的逻辑,最后SSO认证中心需要把用户的token以及用户在子系统的局部会话的x-auth-token返回给用户。

其Json格式为:

{
    "msg": "success",
    "code": 0,
    "data": {
        "user": null,
        "token": "d82ea315-1034-48d2-8e0d-5303c65d4e6a",
        "clientUrl": "http://localhost:8889",
        "authToken": "56e20f4e-f473-4882-8b11-24aa006d1ab3"
    }
}

四 总结

总的来说登录逻辑分为以下几步:

  1. 用户在SSO认证中心进行登录,SSO认证中心将随机生成的token传递给子服务器
  2. 子服务器接收到数据后,对SSO认证中心发起验证请求。
  3. SSO认证中心接收到验证请求后判断token是否合法,若合法则将其加入映射。并将操作结果返回
  4. 子服务器新建Session局部会话,并将x-auth-token返回给SSO认证中心
  5. SSO认证中心将token、x-auth-token、clientUrl返回给用户。用户可根据token进行多系统登录,利用x-auth-token得到与某一子系统的局部会话。

本次的SSO单点登录先写到这里,后面可能会写一篇关于SSO单点登录的注销实现。

项目GitHub地址:https://github.com/attendent/distrubuted

你可能感兴趣的:(架构)