最近在思考单点登录的需求。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登陆中心功能:
- 登录页面显示
- 登录成功的判断
- 登陆成功后的页面跳转
- Token的redis存储
- Token的有效性判断
- 登出时的token处理
Client:
- 拦截器实现身份验证
- 访问SSO中心验证接口,登录页面
- 从SSO中心获取非敏感身份信息,session中存放
- 登出功能,访问SSO中心登出接口
给出登录情况下的时序图,主要思路为: Client设置拦截器,进行session和cookie的查询,没有cookie则跳转到SSO中心进行登录并获取token,传回后存放cookie并跳转原站点。原站点Client此时拦截器获取到cookie信息,访问SSO端口进行验证,验证返回可暴露的用户信息。Client获取到可暴露信息,设置session信息供自己站点后续使用。 登出时序图,主要思路为: Client页面给出登出按钮,删除session中信息,并将cookie中token信息发送到SSO中心进行redis中token删除,之后跳转回login或者client的默认页面。 特别的,为了解决浏览器cookie跨域问题,对跨域的请求我利用RestTemplate 在后端进行处理。
SSO登陆中心
登录中心后端的实现只要包括几个部分:redis处理相关类,数据库相关类和登录,登出,验证接口的逻辑实现。
redis处理相关类
RedisConfig redisTemplate设置类
因为RedisTemplate 默认的序列化比较慢且没有清晰的结构,利用jackson 将其默认序列化设置为jackson 的序列器(虽然整个项目好像都没用到…)。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory redisConnectionFactory) {
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
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,获取对应信息。
public boolean isTokenExist(String token){
return redisUtil.get(token) != null;
}
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
public CommonResponse login(HttpServletResponse response, String userName, String password, String url) throws JsonProcessingException {
if(userService.checkUser(userName, password)){
String time = redisService.addToken(userName);
System.out.println("redis已成功添加token 时效:" + time);
Cookie cookie = new Cookie("accessToken", userName);
cookie.setPath("/");
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
public CommonResponse validate(String token) throws JsonProcessingException {
if(redisService.isTokenExist(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
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>login</title>
<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;
@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);
return factory;
}
跳转login页面方法,设置为跨域方法。需要注意的是,需要将跳转地址后加上当前访问地址作为redirect信息。
@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中心的登陆页面。
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){
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信息。
@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[] 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());
}
JSONObject jsonObject = new JSONObject();
jsonObject.put("message", "/main");
return jsonObject.toJSONString();
}
main.html 前端实现
就是简单展示下用户信息,看看成没成。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>main</title>
<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({
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站点 登录: 登出:
可以看出,登录成功保存token,并获取了session中信息。通过验证即可保持登陆状态。 登出时,清除了session信息,token从缓存中被删除,只能重新登录。
总结
这篇简单整理了基于登陆中心的SSO单点登陆方法,客户端模块化的话,只要额外的配置文件和拦截器设置即可。 真实业务里如果token被获取可能会有csrf攻击等问题,考虑csrf伪token,设置cookie域(项目里直接设置成了“/”)之类的方法进行防御。总的来说就是个简单demo尝试,感兴趣的看看吧。
|