前置知识
一、早期单一服务器,用户认证
当浏览器向服务器发送登录请求时,验证通过之后,会将用户信息存入DB或者文件中
缺点:单点性能压力,无法扩展
二、WEB应用集群,session共享模式
引入集群的概念,单应用可能重新部署在3台tomcat以上服务器,使用nginx来实现反向代理,解决了单点性能瓶颈。
缺点:
1、 多业务分布式数据独立管理,不适合统一维护一份session数据。
2、 分布式按业务功能切分,用户、认证解耦出来单独统一管理。
3、 cookie中使用sessionId 容易被篡改、盗取。
4、 跨顶级域名无法访问。
三、分布式,SSO(single sign on)模式
解决了用户信息独立,更好的分布式管理,可以自己扩展安全策略,解决了跨域问题。
缺点:
认证服务器访问压力较大。
几个基本概念
一、什么是跨域 Web SSO
域名通过“.”号切分后,从右往左看,不包含“.”的是顶级域名,包含一个“.”的是一级域名,包含两个“.”的是二级域名,以此类推。
例如对网址 http://www.cnblogs.com/baibaomen,域名部分是 www.cnblogs.com。用“.”拆分后从右往左看:
cookie.setDomain(“.cnblogs.com”);//最多设置到本域的一级域名这里
cookie.setDomain(“.baidu.com”);//最多设置到本域的一级域名这里
”com”不包含“.”,是顶级域名; “cnblogs.com”包含一个“.”,是一级域名;www.cnblogs.com 包含两个“.”,是二级域名。
跨域 Web SSO 指的是针对 Web 站点,各级域名不同都能处理的单点登录方案。
二、 cookie 的安全性限制:一级或顶级域名不同的网站无法读到彼此写的cookie。
所以 baidu.com 无法读到 cnblogs.com 写的 cookie。一级域名相同,只是二级或更高级域名不同的站点,可以通过设置 domain 参数共享 cookie读写。这种场景可以选择不跨域的 SSO 方案。
域名相同,只是 https 和 http 协议不同的 URL,默认 cookie 可以共享。知道这一点对处理 SSO 服务中心要登出
三、 协议是无状态协议。浏览器访问服务器时,要让服务器知道你是谁,只有两种方式:
方式一:把“你是谁”写入 cookie。它会随每次 HTTP 请求带到服务端;
方式二:在 URL、表单数据中带上你的用户信息(也可能在 HTTP 头部)。这种方式依赖于从特定的网页入口进入,因为只有走特定的入口,才有机会拼装出相应的信息,提交到服务端。
大部分 SSO 需求都希望不依赖特定的网页入口(集成门户除外),所以后一种方式有局限性。适应性强的方式是第一种,即在浏览器通过 cookie 保存用户信息相关凭据,随每次请求传递到服务端。我们采用的方案是第一种。
四、JWT实现无状态登录
JWT,全称是 Json Web Token, 是 JSON 风格轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权。
因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了 Rest 的无状态规范。
搭建OSS认证中心
一、完整业务流程
二、代码实现
本次示例使用SpringBoot工程搭建
1.新建OSS认证中心模块
核心依赖
<parent>
<groupId>com.atguigu.gmall</groupId>
<artifactId>gmall-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<dependencies>
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>gmall-interface</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>gmall-web-util</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
application.properties
server.port=8086
spring.thymeleaf.cache=false
spring.thymeleaf.mode=LEGACYHTML5
spring.dubbo.application.name=passport-web
spring.dubbo.registry.protocol=zookeeper
spring.dubbo.registry.address=192.168.67.163:2181
spring.dubbo.base-package=com.atguigu.gmall
spring.dubbo.protocol.name=dubbo
spring.dubbo.consumer.timeout=100000
spring.dubbo.consumer.check=false
2.登录页面html代码
<div class="si_bom1 tab" style="display: block;">
<div class="error">
请输入账户名和密码
</div>
<form id="loginForm" action="/login" method="post">
<ul>
<li class="top_1">
<img src="/img/user_03.png" class="err_img1"/>
<input type="text" name="loginName" placeholder=" 邮箱/用户名/已验证手机" class="user"/>
</li>
<li>
<img src="/img/user_06.png" class="err_img2"/>
<input type="password" name="passwd" placeholder=" 密码" class="password"/>
</li>
<li class="bri">
<a href="">忘记密码</a>
</li>
<li class="ent"><button id="btn2" class="btn2"><a class="a">登 录</a></button></li>
</ul>
<input type="hidden" id="originUrl" name="originUrl" th:value="${originUrl}"/>
</form>
</div>
3.登录功能(生成token)
认证中心只负责认证和token的颁发
-
用接受的用户名密码核对后台数据库
-
将用户信息加载到写入redis,redis中有该用户视为登录状态。
-
用userId+当前用户登录ip地址+密钥生成token
-
重定向用户到之前的来源地址,同时把token作为参数附上。
1)核对后台登录信息+用户登录信息载入缓存
public UserInfo login(UserInfo userInfo){
String passwd = DigestUtils.md5Hex(userInfo.getPasswd());
userInfo.setPasswd(passwd);
UserInfo userInfoResult = userInfoMapper.selectOne(userInfo);
if(userInfoResult!=null){
String userInfoKey="user:"+userInfoResult.getId()+"info";
Jedis jedis = redisUtil.getJedis();
String userInfoJson = JSON.toJSONString(userInfoResult);
jedis.setex(userInfoKey,UserConst.sessionExpire,userInfoJson);
return userInfoResult ;
}else{
return null;
}
}
2)使用JWT工具类生成tooken
public class JwtUtil {
public static String encode(String key,Map<String,Object> param,String salt){
if(salt!=null){
key+=salt;
}
JwtBuilder jwtBuilder = Jwts.builder().signWith(SignatureAlgorithm.HS256,key);
jwtBuilder = jwtBuilder.setClaims(param);
String token = jwtBuilder.compact();
return token;
}
public static Map<String,Object> decode(String token ,String key,String salt){
Claims claims=null;
if (salt!=null){
key+=salt;
}
try {
claims= Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
} catch ( JwtException e) {
return null;
}
return claims;
}
}
3)Controller示例
@RequestMapping(value ="login",method = RequestMethod.POST)
@ResponseBody
public String login(UserInfo userInfo,HttpServletRequest httpServletRequest){
String remoteAddr = httpServletRequest.getHeader("x-forwarded-for");
String userId = userManageService.login(userInfo);
if(userId==null){
return "fail";
}else{
Map map=new HashMap();
map.put("userId",userId);
map.put("nickName",userInfoResult.getNickName());
String token = JwtUtil.encode(signKey, map, remoteAddr);
return token;
}
}
4 验证登录(token)功能,用jwt解析
当业务模块某个页面要检查当前用户是否登录时,提交到认证中心,认证中心进行检查校验,返回登录状态、用户Id和用户名称。
-
利用密钥和IP检验token是否正确,并获得里面的userId
-
用userId检查Redis中是否有用户信息,如果有延长它的过期时间。
-
登录成功状态返回。
UserController示例
@RequestMapping(value ="verify",method = RequestMethod.POST)
@ResponseBody
public String verify(HttpServletRequest httpServletRequest){
String token = httpServletRequest.getParameter("token");
String curIp=httpServletRequest.getParameter("currentIp");
Map<String, Object> map = JwtUtil.decode(token, signKey, curIp);
JSONObject jsonObject=new JSONObject();
String userId =(String) map.get("userId");
boolean verify = userManageService.verify(userId);
return Boolean.toString(verify) ;
}
UserManageServiceImpl示例
public boolean verify(String userId){
Jedis jedis = redisUtil.getJedis();
String userInfoKey="user:"+userId+"info";
Boolean exists = jedis.exists(userInfoKey);
if(exists) {
jedis.expire(userInfoKey, UserConst.sessionExpire);
}
return exists;
}
5.业务模块的登录检查(@注解与拦截器)
1)登录拦截器
继承成springmvc的HandlerInterceptorAdapter,通过重新它的preHandle方法实现,业务代码前的校验工作,所有web模块都需要的。
@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter{
@Autowired
AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(authInterceptor).addPathPatterns("/**");
super.addInterceptors(registry);
}
}
2)登录成功后跳转回来的处理(登录成功后写入cookie)
@Component
public class AuthInterceptor extends HandlerInterceptorAdapter
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String newToken = request.getParameter("newToken");
if(newToken!=null&&newToken.length()>0){
CookieUtil.setCookie(request,response,"token",newToken,WebConst.cookieExpire,false);
}
return true;
}
}
其中用到了CookieUtil的工具,主要三个方法:从cookie中获得值,把值存入cookie, 设定cookie的作用域。代码如下:
public class CookieUtil {
public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookieName == null){
return null;
}
String retValue = null;
try {
for (int i = 0; i < cookies.length; i++) {
if (cookies[i].getName().equals(cookieName)) {
if (isDecoder) {//如果涉及中文
retValue = URLDecoder.decode(cookies[i].getValue(), "UTF-8");
} else {
retValue = cookies[i].getValue();
}
break;
}
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return retValue;
}
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
try {
if (cookieValue == null) {
cookieValue = "";
} else if (isEncode) {
cookieValue = URLEncoder.encode(cookieValue, "utf-8");
}
Cookie cookie = new Cookie(cookieName, cookieValue);
if (cookieMaxage >= 0)
cookie.setMaxAge(cookieMaxage);
if (null != request)// 设置域名的cookie
cookie.setDomain(getDomainName(request));
cookie.setPath("/");
response.addCookie(cookie);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 得到cookie的域名
*/
private static final String getDomainName(HttpServletRequest request) {
String domainName = null;
String serverName = request.getRequestURL().toString();
if (serverName == null || serverName.equals("")) {
domainName = "";
} else {
serverName = serverName.toLowerCase();
serverName = serverName.substring(7);
final int end = serverName.indexOf("/");
serverName = serverName.substring(0, end);
final String[] domains = serverName.split("\\.");
int len = domains.length;
if (len > 3) {
// www.xxx.com.cn
domainName = domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
} else if (len <= 3 && len > 1) {
// xxx.com or xxx.cn
domainName = domains[len - 2] + "." + domains[len - 1];
} else {
domainName = serverName;
}
}
if (domainName != null && domainName.indexOf(":") > 0) {
String[] ary = domainName.split("\\:");
domainName = ary[0];
}
System.out.println("domainName = " + domainName);
return domainName;
}
}
3)检查cookie中是否有token
-
检查cookie中是否有token,如果有把cookie中的昵称取放入页面request属性中。
-
检查是否需要验证登录。如果需要,调用认证模块接口。
如果认证通过程序照常执行
如果认证不通过,跳转到登录页面
-
检查是否是登陆页面跳转回来的,如果附带新的token则把token保存到cookie中。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String newToken = request.getParameter("newToken");
if(newToken!=null&&newToken.length()>0){
CookieUtil.setCookie(request,response,"token",newToken,WebConst.cookieExpire,false);
}
//1 进行如果能从cookie把token取出来,进行解析,显示页面上。
String token = CookieUtil.getCookieValue(request, "token", false);
String userId=null;
if(token!=null){
Base64UrlCodec base64UrlCodec=new Base64UrlCodec();
// 两个“.”之间的部分是实际内容
String tokenForDecode= StringUtils.substringBetween(token, ".");
byte[] tokenByte = base64UrlCodec.decode(tokenForDecode);
String tokenJson=new String(tokenByte,"UTF-8");
System.out.println("tokenJson = " + tokenJson);
JSONObject jsonObject = JSON.parseObject( tokenJson);
userId = jsonObject.getString("userId");
String nickName = jsonObject.getString("nickName");
request.setAttribute("nickName",nickName);
}
return true;
}
4)通过注@ LoginRequie解来检验方法是否需要验证
为了方便程序员在controller方法上标记,可以借助自定义注解的方式。比如某个controller方法需要验证用户登录,在方法上加入自定义的@LoginRequie。
自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequire {
boolean autoRedirect() default true;
}
在拦截方法preHandle方法中继续添加,检验登录的代码
//检查是否需要验证用户已经登录
HandlerMethod handlerMethod =(HandlerMethod) handler;
LoginRequire methodAnnotation = handlerMethod.getMethodAnnotation(LoginRequire.class);
if(methodAnnotation!=null){
String currentIp = request.getHeader("x-forwarded-for");
Map map=new HashMap();
map.put("currentIp",currentIp);
map.put("token",token);
String result=null;
if(token !=null) {
result = HttpclientUtil.doPost(WebConst.VERIFY_URL, map);
}
if(result!=null&&result.equals("true")){
request.setAttribute("userId",userId); //只有验证过才能取到userId
return true;
}else{
if(methodAnnotation.autoRedirect()) {
String url = URLEncoder.encode(request.getRequestURL().toString(), "utf-8");
response.sendRedirect(WebConst.LOGIN_URL + "?originUrl=" + url);
return false;
}
}
}
以上方法,检查业务方法是否需要用户登录,如果需要就把cookie中的token和当前登录人的ip地址发给远程服务器进行登录验证,返回的result是验证结果true或者false。如果验证未登录,直接重定向到登录页面。
以上使用到了一个自定义的HttpclientUtil工具类,专门负责通过restful风格调用接口。
public class HttpclientUtil {
public static String doGet(String url) {
// 创建Httpclient对象
CloseableHttpClient httpclient = HttpClients.createDefault();
// 创建http GET请求
HttpGet httpGet = new HttpGet(url);
CloseableHttpResponse response = null;
try {
// 执行请求
response = httpclient.execute(httpGet);
// 判断返回状态是否为200
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
HttpEntity entity = response.getEntity();
String result = EntityUtils.toString(entity, "UTF-8");
EntityUtils.consume(entity);
httpclient.close();
return result;
}
httpclient.close();
}catch (IOException e){
e.printStackTrace();
return null;
}
return null;
}
public static String doPost(String url, Map<String,String> paramMap) {
// 创建Httpclient对象
CloseableHttpClient httpclient = HttpClients.createDefault();
// 创建http Post请求
HttpPost httpPost = new HttpPost(url);
CloseableHttpResponse response = null;
try {
List<BasicNameValuePair> list=new ArrayList<>();
for (Map.Entry<String, String> entry : paramMap.entrySet()) {
list.add(new BasicNameValuePair(entry.getKey(),entry.getValue())) ;
}
HttpEntity httpEntity=new UrlEncodedFormEntity(list,"utf-8");
httpPost.setEntity(httpEntity);
// 执行请求
response = httpclient.execute(httpPost);
// 判断返回状态是否为200
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
HttpEntity entity = response.getEntity();
String result = EntityUtils.toString(entity, "UTF-8");
EntityUtils.consume(entity);
httpclient.close();
return result;
}
httpclient.close();
}catch (IOException e){
e.printStackTrace();
return null;
}
return null;
}
}
核心思想
-
给登录服务器留下登录痕迹
-
登录服务器要将token信息重定向的时候,带到url地址上
-
其他系统要处理url地址上的关键token,只要有,将token对用的用户保存到自己的session中
-
自己系统将用户保存在自己的会话中