(1)使用环境:
SpringBoot2.X
MyBatis
基于redis存储的springSession
(2)基础学习:
关于SSO的基础学习可以参考该文章:单点登录(SSO),从原理到实现
代码风格使用的是晓风轻的代码规范,对于其中的AOP实现此处不会给出代码,具体可以在文章尾部的gitHub上查看:我的编码习惯 - Controller规范
进阶可以参考:单点登录(一)-----理论-----单点登录SSO的介绍和CAS+选型
(3)目标
(4)注意:
看完以上文章之后总结一下,在这次简单实现中我们需要做到的有以下几点:
首先来看下基础工具类,比较多,不想看的朋友可以跳到下一大节,下面看到有不清楚的方法可以搜索回来这里看。以下在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"
}
操作流程:
不过大家应该发现了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"
}
}
总的来说登录逻辑分为以下几步:
x-auth-token
返回给SSO认证中心本次的SSO单点登录先写到这里,后面可能会写一篇关于SSO单点登录的注销实现。
项目GitHub地址:https://github.com/attendent/distrubuted