文章目录
概要
SSO(single sign on)单点登录是一种身份验证机制,允许用户使用同一组凭据在多个应用程序或者系统中进行身份验证,而不需要对每一个用户程序用不同的凭据进行身份验证。
而实现SSO的一种常用方式就是使用令牌(token)的方式来管理用户的身份验证状态
系统架构
springboot+springcloud阿里巴巴+redis
基本实现步骤
1.用户从页面A跳转到登录页面后输入用户名密码进行登录
2.后台将接收到的用户名密码进行验证
3.验证通过后会用uuid生成token并且生成用户信息
4.以token为键将用户id和ip地址存入redis以便以后验证用户身份
5.将token和用户信息返回到前端
6.前端将token和用户信息保存到cookie中
7.重定向到用户进行登录的页面的之前的页面A
8.在页面A用cookie进行身份验证,通过后页面A显示用户信息
9.用户去访问页面B(页面B是必须需要登录才能访问的页面),首先进入网关的过滤器,①判断是否是微服务中调用内部接口(比如使用openfeign进行的接口调用),如果是,直接返回没有权限信息,这种接口属于服务器内部调用接口,不需要用户请求,如果不是继续进行下一步。②获取用户信息,通过页面传过来的cookie信息来拿到token,然后去查找以它为键去查找redis中是否有值,如果有值,从中取出ip地址和用户id,首先来判断用户的ip地址是否和存入redis时的ip地址相同,如果不相同返回错误信息,防止token被盗用,如果相同则进行下一步。③判断请求路径是否是需要登录的api异步接口,如果是,则根据第二步取出来的用户id判断是否为空,如果为空,返回未登录的错误信息。否则直接放行④判断请求路径是否是必须要登录的地址,如果是,则判断用户id是否为空,如果为空,则直接跳转到登录页面进行登录,否则就直接放行
10.由于cookie已经保存在浏览器中,因此页面B可以在过滤器的第四步中放行,放行后在页面B用浏览器中已存放的cookie进行身份验证,通过后在页面显示用户信息 访问其他页面同上
***注意点:一般来说单点登录在设置cookie的时候,可以将domain设置为父域名,而你需要访问的页面,都设置为父域名的子域名,例如gmall.com和list.gmall.com这样他们都能同时拥有你所设置的cookie
我由于一些原因没有设置域名而用本机回环地址代替
页面A虽然没有提及,但是需要注意的是访问每一个页面都需要先进入网关的过滤器,因为网关是统一访问路口,请求都是先进入网关, 再由网关进行映射
***
代码实现
1.用户点击登录之后后台进行用户验证 上面 1-5点
/**
* "title": "登录",
* "path": "/api/user/passport/login",
*/
@PostMapping("login")
public Result login(@RequestBody UserInfo userInfo, HttpServletRequest request){
//从后台数据库查询是否有此用户
UserInfo userInfoPassport= userInfoService.login(userInfo);
if (userInfoPassport!=null){
Map<String,Object> map=new HashMap<>();
//uuid生成token
String token= UUID.randomUUID().toString().replaceAll("-","");
//将用户信息和token存入map中返回
map.put("nickName",userInfoPassport.getNickName());
map.put("token",token);
//将用户id和访问的ip地址存入redis中以便后续网关过滤器进行验证
JSONObject jsonObject=new JSONObject();
jsonObject.put("userId",userInfoPassport.getId().toString());
jsonObject.put("IP", IpUtil.getIpAddress(request));
redisTemplate.opsForValue().set(RedisConst.USER_LOGIN_KEY_PREFIX+token,jsonObject.toJSONString(),RedisConst.USERKEY_TIMEOUT, TimeUnit.SECONDS);
return Result.ok(map);
}
return Result.fail().message("登录失败,用户名或密码错误");
}
2.网关过滤器进行鉴权上面第9点
package com.atguigu.gmall.gateway.filter;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.common.result.ResultCodeEnum;
import com.atguigu.gmall.common.util.IpUtil;
import com.netflix.client.ssl.URLSslContextFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
@Component
public class AuthGlobalFilter implements GlobalFilter {
@Autowired
private RedisTemplate redisTemplate;
//匹配路径的工具类
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@Value("${authUrls.url}")
private String authUrls;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取请求对象
ServerHttpRequest request = exchange.getRequest();
//获取URL
String path = request.getURI().getPath();
//让静态资源直接放行
String staticResource="css,data,fonts,img,js,hm.js";
String[] split1 = staticResource.split(",");
for (String s : split1) {
if (path.indexOf(s)!=-1){
return chain.filter(exchange);
}
}
//如果是内部接口
if (antPathMatcher.match("/**/inner/**", path)) {
//返回没有权限
return out(exchange.getResponse(), ResultCodeEnum.PERMISSION);
}
//获取用户id
String userId = this.getUserId(request);
if (userId.equals("-1")){
//如果返回-1表示请求的ip地址于之前redis中存的ip地址不一致,返回非法请求
return out(exchange.getResponse(),ResultCodeEnum.ILLEGAL_REQUEST);
}
//如果是auth需要鉴权的接口
if (antPathMatcher.match("/api/**/auth/**",path)){
if (StringUtils.isEmpty(userId)){
//如果用户id为空表示没有登录返回未登录
return out(exchange.getResponse(),ResultCodeEnum.LOGIN_AUTH);
}else{
return chain.filter(exchange);
}
}
//验证白名单,网关中的url
String[] split = authUrls.split(",");
if (split!=null){
for (String s : split) {
//当前请求地址包含登录的控制器域名并且用户id为空
if (path.indexOf(s)!=-1&&StringUtils.isEmpty(userId)){
//如果用户id为空表示没有登录需要重定向到登录页面
//303状态码表示由于请求对应的资源存在着另一个URI,应使用重定向获取请求的资源
exchange.getResponse().setStatusCode(HttpStatus.SEE_OTHER);
System.out.println(request.getURI());
exchange.getResponse().getHeaders().set(HttpHeaders.LOCATION,"http://127.0.0.1:9001/login/login.html?originUrl="+request.getURI());
//重定向到登录
return exchange.getResponse().setComplete();
}
}
}
//将userId传给后端 方便后续操作
if (!StringUtils.isEmpty(userId)){
request.mutate().header("userId",userId).build();
return chain.filter(exchange.mutate().request(request).build());
}
return chain.filter(exchange);
}
private String getUserId(ServerHttpRequest request) {
//从头信息中查看是否有token 一般是异步请求
HttpHeaders headers = request.getHeaders();
String token = "";
List<String> list = headers.get("token");
if (!CollectionUtils.isEmpty(list)){
token= list.get(0);
}
//从cookie中查看是否有token 一般是同步请求
HttpCookie httpCookie = request.getCookies().getFirst("token");
if (httpCookie!=null){
token = httpCookie.getValue();
}
if (!StringUtils.isEmpty(token)){
//从redis中拿到token所对应的值
Object object = redisTemplate.opsForValue().get("user:login:"+ token);
String resultString="";
if (object!=null){
resultString = object.toString();
}
if (!StringUtils.isEmpty(resultString)){
JSONObject jsonObject = JSONObject.parseObject(resultString);
String userId = jsonObject.getString("userId");
//获取登录时候的ip
String ip = jsonObject.getString("IP");
//获取当前请求的ip
String curIP = IpUtil.getGatwayIpAddress(request);
if (!curIP.equals(ip)){
return "-1";
}
return userId;
}
}
return "";
}
/**
* 制作鉴权失败返回信息
*
* @param response
* @param permission
* @return
*/
private Mono<Void> out(ServerHttpResponse response, ResultCodeEnum permission) {
Result<Object> build = Result.build(null, permission);
byte[] bytes = JSONObject.toJSONString(build).getBytes(StandardCharsets.UTF_8);
DataBuffer dataBuffer = response.bufferFactory().wrap(bytes);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(dataBuffer));
}
}
结果展示
用户第一次进入首页
利用cookie进行身份验证的前端代码:
//获取cookie中的用户信息
showInfo() {
// debugger
if(auth.getUserInfo()) {
this.userInfo = auth.getUserInfo()
console.log("--------"+this.userInfo.nickName)
}
}
//js文件中获取cookie的具体方法
getUserInfo() {
if ($.cookie('userInfo')) {
return JSON.parse($.cookie('userInfo'))
}
return null
}
//用户信息对象的定义
data: {
// keyword: [[${searchParam?.keyword}]],
userInfo: {
nickName: '',
name: ''
}
}
//用户信息的展示
<li v-if="userInfo.nickName == ''" class="f-item">请<span><a href="javascript:" @click="login()">登录</a></span> <span><a href="#">免费注册</a></span></li>
<li v-if="userInfo.nickName != ''" class="f-item"><span>{{userInfo.nickName}}</span> <span><a href="javascript:" @click="logout()">退出</a></span></li>
可以看到在第一次进入首页的时候,因为cookie中没有token和用户信息,所以不显示用户名,显示的是请登录
用户点击登录按钮
登录之后
在登录的时候,会将用户信息发给后台进行验证,验证通过,就会将生成的token和userinfo信息返回到前端,前端再将它保存到cookie里,之后再跳转到登录之前的页面
后端验证的代码已经展示,这里展示前端设置cookie和跳转页面的代码:
//登录的方法
submitLogin() {
/*后台就可以使用UserInfo 对象接收*/
login.login(this.user).then(response => {
// debugger
if (response.data.code == 200) {
// 把token存在cookie中、也可以放在localStorage中
// result.ok(map);
// response 相当于Result
// response.data 相当于 Result.ok();
// response.data.data data看做一个map 相当于 Result.ok(data);
// response.data.map.token 相当于 result.ok(map)
// map 中有token 同时还有 nickName
auth.setToken(response.data.data.token)
// response.data.data 相当于返回来的map
// JSON.stringify(response.data.data) 将map 数据变为json对象,存储到cookie 中
auth.setUserInfo(JSON.stringify(response.data.data))
// 输入日志:是否有 originUrl 回跳url!
// originUrl = 记录用户在哪点击的登录url ,当用户登录成功之后,又跳转到了原来的url!
console.log("originUrl:"+this.originUrl);
if(this.originUrl == ''){
window.location.href="http://127.0.0.1:9001/index"
return ;
} else {
//重定向到页面的登录前页面
window.location.href = decodeURIComponent(this.originUrl)
}
} else {
alert(response.data.data.message)
}
})
}
//设置cookie的具体代码
setToken(token) {
return $.cookie('token', token, { expires: 7, path: '/'})
},
setUserInfo(userInfo) {
return $.cookie('userInfo', userInfo, { expires: 7, path: '/'})
}
再访问另一个需要登录的页面
从上面的代码可以看出我的cookie已经设置过了,path为/表示根路径,由于在设置cookie的时候没有设置domain,所以cookie可以有效的域名默认是设置cookie的页面,由于设置cookie的页面是登录页面,而登录页面的ip地址是127.0.0.1,因此只要ip地址是127.0.0.1的页面都拥有这样的cookie,因此可以正常显示用户名
如果退出登录,再次访问需要登录的页面(还是上文的list页面)
由于退出登录以后,浏览器中cookie的token和userinfo都置为了空,redis中存储的这个用户的信息也被删除,而在请求这个页面的时候,首先进入的是网关的过滤器,则会执行这些代码
//当前请求地址包含登录的控制器域名并且用户id为空
if (path.indexOf(s)!=-1&&StringUtils.isEmpty(userId)){
//如果用户id为空表示没有登录需要重定向到登录页面
//303状态码表示由于请求对应的资源存在着另一个URI,应使用重定向获取请求的资源
exchange.getResponse().setStatusCode(HttpStatus.SEE_OTHER);
System.out.println(request.getURI());
exchange.getResponse().getHeaders().set(HttpHeaders.LOCATION,"http://127.0.0.1:9001/login/login.html?originUrl="+request.getURI());
//重定向到登录
return exchange.getResponse().setComplete();
}
由于token值已经为空,因此无法取出用户id,因此就会被重定向到登录页面进行登录
如果登录成功,后端又返回了token和userinfo数据,前端也设置了cookie,之后就能跳转到登录之前所访问的页面,正常的显示出用户信息
在list页面登录成功后
小问题
为什么用户在登录之后会跳转到登录之前的页面呢?
这是由于在跳转到登录页面的时候,会添加一个以originUrl为键以请求源地址为值的参数,而在登录成功的时候会判断originUrl是否为空,如果为空,跳转到首页,如果不为空,就跳转到originUrl所携带的地址
代码展示:
login() {
//前端跳转到登录页面时
//window.location.href:页面的跳转
//window.location.href:获取地址栏的内容
/*路径一样不要设置错我设置成了127.0.1.1*/
window.location.href = 'http://127.0.0.1:9001/login/login.html?originUrl='+window.location.href
}
//后端在过滤器中,哪些需要登录的页面验证token失败,跳转到登录页面的代码
if (path.indexOf(s)!=-1&&StringUtils.isEmpty(userId)){
//如果用户id为空表示没有登录需要重定向到登录页面
//303状态码表示由于请求对应的资源存在着另一个URI,应使用重定向获取请求的资源
exchange.getResponse().setStatusCode(HttpStatus.SEE_OTHER);
System.out.println(request.getURI());
exchange.getResponse().getHeaders().set(HttpHeaders.LOCATION,"http://127.0.0.1:9001/login/login.html?originUrl="+request.getURI());
//重定向到登录
return exchange.getResponse().setComplete();
}
//前端在登录成功并且设置完Cookie信息后跳转的代码
if(this.originUrl == ''){
window.location.href="http://127.0.0.1:9001/index"
return ;
} else {
//重定向到页面的登录前页面
window.location.href = decodeURIComponent(this.originUrl)
}