1.需要实现的功能
①用户在任意模块进行登陆操作后,在系统中其它模块能查询到当前登陆用户的信息
②用户在登陆过一次系统后,应该记忆用户的用户名,下次不必登陆就可以在页面显示用户名,但是实际状态是未登录,且有过期时间
③用户在任意页面的登录按钮,登录后能回到原来的页面.
2.实现的思路
登陆服务单独创建一个用户登陆认证中心模块(分布式,SSO(single sign on)模式
对于第一条:①用户在任意模块进行登陆操作后,在系统中其它模块能查询到当前登陆用户的信息
这个是单点登录的最基本的功能,可以将用户登陆后的信息放在redis缓存中,每次用户登陆就设到缓存中,采用redis的str数据类型.key可以为user:{userId}:info,value可以设置为用户信息的json字符串.
服务中登陆的方法:
//key的前缀
public final String userKey_prefix = "user:";
//key的后缀
public final String userinfoKey_suffix=":info";
//过期时间 一个小时
public final int userKey_timeOut=60*60;
public UserInfo login(UserInfo userInfo) {
//密码的加密 DigestUtils是加密的工具类
userInfo.setPasswd(DigestUtils.md5DigestAsHex(userInfo.getPasswd().getBytes()));
//从数据库中查找用户是否存在
UserInfo loginUserInfo = userInfoMapper.selectOne(userInfo);
if(loginUserInfo!=null){
//redisUtil也是操作redis的工具类
Jedis jedis = redisUtil.getJedis();
String key = userKey_prefix+loginUserInfo.getId()+userinfoKey_suffix;
//保存到redis中
jedis.setex(key, userKey_timeOut, JSON.toJSONString(LoginUserInfo));
jedis.close();
}
return loginUserInfo;
}
当其他模块需要用户的登录信息时,就可以根据用户的userId直接去redis中查找.
//根据userId进行登陆认证
public UserInfo verify(String userId){
//去缓存中查询是否有对应userId的用户信息
Jedis jedis = redisUtil.getJedis();
//拼接key值前缀+userId+后缀
String key = userKey_prefix+userId+userinfoKey_suffix;
String userInfoJson = jedis.get(key);
if(userInfoJson!=null&&userInfoJson.trim().length()>0){
//给这个key重新设置失效时间
jedis.expire(key, userKey_timeOut);
UserInfo userInfo = JSON.parseObject(userInfoJson, UserInfo.class);
return userInfo;
}
return null;
}
对于第二三条:
②用户在登陆过一次系统后,应该记忆用户的用户名,下次不必登陆就可以在页面显示用户名,但是实际状态是未登录,且有过期时间.
③用户在任意页面的登录按钮,登录后能回到原来的页面.
这两个需求需要利用浏览器的cookie功能或者localStorage本地存储功能.这里只说利用cookie来实现.并利用一个简单的token机制
具体做法为:
- 用userId+当前用户登录ip地址+密钥生成token
- 重定向用户到之前的来源地址,同时把token作为参数附上。
- 将token设置到用户的浏览器cookie中,并设置一个过期时间,通过解密token就可以实现第二个需求
首先页面的所有登陆入口应该用js代码来控制,每次点击登陆按钮登陆时,应该带上一个originUrl的参数,用于保存登陆后需要跳转的页面控制器,一般设置为当前页面
function login(){
//使用encodeURIComponent()函数对url进行编码
var s = encodeURIComponent("http://xxx.xxx.com/cartList");
window.location.href="http://passport.xxx.com/index?originUrl="+s;
}
然后进入登陆页面的跳转控制器,将originUrl保存到request域中
@RequestMapping("/index")
public String passport(HttpServletRequest request){
String originUrl = request.getParameter("originUrl");
// 保存上
request.setAttribute("originUrl",originUrl);
return "index";
}
在登陆页面的登陆表单提交方法中将originUrl取出,如果登陆成功就跳转到相应的页面跳转控制器
$.post("/login",$("#loginForm").serialize(),function (data) {
//登陆的回调函数
if(data&&data!='fail'){
var originUrl = $("#originUrl").val();
console.log("originUrl:"+originUrl);
if(originUrl==''){
//如果没有回跳页面就跳转到首页
window.location.href="http://www.xxx.com?newToken="+data;
return ;
}
//解码
originUrl = decodeURIComponent(originUrl);
var idx=originUrl.indexOf('?');
if(idx<0){
originUrl+='?'
}else{
originUrl+='&'
}
//拼接newToken token在登陆控制器"/login"中生成,并返回给页面使用
window.location.href=originUrl+"newToken="+data;
return ;
}else{
$(".error").text("用户名密码错误!");
$(".error").show();
}
} );
在登陆控制器"/login"中生成token.我们在这里使用JWT(Json Web Token)工具.
简单的介绍一下JWT工具:
JWT(Json Web Token) 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准.
JWT 最重要的作用就是对 token信息的防伪作用。
JWT的原理:
一个JWT由三个部分组成:公共部分、私有部分、签名部分。最后由这三者组合进行base64编码得到JWT。
公共部分
主要是该JWT的相关配置参数,比如签名的加密算法、格式类型、过期时间等等。
私有部分
用户自定义的内容,根据实际需要真正要封装的信息。
签名部分
根据用户信息+盐值+密钥生成的签名。如果想知道JWT是否是真实的只要把JWT的信息取出来,加上盐值和服务器中的密钥就可以验证真伪。所以不管由谁保存JWT,只要没有密钥就无法伪造。
这里使用服务器IP作为盐值
用户信息+ip=密钥:
iP:当前服务器的Ip地址!
base64编码,并不是加密,只是把明文信息变成了不可见的字符串。但是其实只要用一些工具就可以吧base64编码解成明文,所以不要在JWT中放入涉及私密的信息,因为实际上JWT并不是加密信息。
使用JWT需要在项目中加入依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
制作一个JWT的工具类
public class JwtUtil {
//生成token
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;
}
//解密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;
}
}
"/login"控制器
@RequestMapping("/login")
@ResponseBody
public String login(UserInfo userInfo,HttpServletRequest request){
//获取服务器ip地址 需要在nginx配置文件中的反向代理配置中配置一个参数才能使用
String remoteAddr = request.getHeader("X-forwarded-for");
if(userInfo!=null){
//调用服务中的login方法
UserInfo loginUser = userInfoService.login(userInfo);
if(loginUser!=null){
//生成token
Map<String,Object> map = new HashMap<>();
map.put("userId", loginUser.getId());
map.put("nickName", loginUser.getNickName());
//signKey是一个公钥参数,可定一个常量或者在配置文件中设置
String token = JwtUtil.encode(signKey, map, remoteAddr);
return token;
}
}
return "fail";
}
这样,就实现了在任意页面跳转到登陆页面后,再回到原来的页面,并携带了我们设置的token参数,用于解密用户信息
但是还差最关键的一步,将token设置到用户浏览器的cookie中
因为用户在访问系统时,有些页面或者功能需要登陆才能访问,有些不需要登陆,为了方便管理.我们做个拦截器.拦截所有的请求,在拦截器中判断是否需要登陆.而我们的token就在拦截器里设置到cookie中.
首先自定义一个拦截器:
拦截器的配置:
@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter{
@Autowired
//接下来要完成的自定义拦截器
AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry){
//将自定义拦截器增加到springmvc的拦截器集合中,并配置拦截的路径为所有("/**")
registry.addInterceptor(authInterceptor).addPathPatterns("/**");
super.addInterceptors(registry);
}
}
自定义拦截器:可以继承HandlerInterceptorAdapter也可以实现HandlerInterceptor接口
HandlerInterceptor拦截器接口中有三个方法preHandler,postHandler,afterCompletion,不熟悉执行时机的回去补课
@Component
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//可能存在刚登陆还没有写到cookie中的token
String token = request.getParameter("newToken");
//如果第一步获取了token 说明刚登陆还没有写到cookie中 要在这里写入
if(token!=null){
CookieUtil.setCookie(request, response, "token", token, WebConst.COOKIE_MAXAGE, false);
}
//如果第一步没有获取token 说明不是刚登陆 url中没有newToken 或者根本就没有登陆 这里进行判断是哪种情况
if(token==null){
//尝试从cookie中获取token 第三个参数为是否进行UTF-8解码 如果涉及中文
token = CookieUtil.getCookieValue(request, "token", false);
}
//从cookie中获取到了token 可能是之前登陆的保存在浏览器cookie中的token也可能是已经登陆了设置到cookie中的token 后边还需要认证去redis中认证判断
if (token!=null){
// 解密token
Map map = getUserMapByToken(token);
// map.get(); 取得到nickName
String nickName = (String) map.get("nickName");
// 将用户的昵称,保存到作用域中,为了在页面显示
request.setAttribute("nickName",nickName);
}
//判断当前请求是否需要登陆才能访问 这里使用了一个自定义注解 用于标识控制器方法是否需要登陆才能访问
HandlerMethod handlerMethod = (HandlerMethod) handler;
LoginRequire methodAnnotation = handlerMethod.getMethodAnnotation(LoginRequire.class);
if(methodAnnotation!=null){
//说明标识了自定义注解
//获取服务器ip地址 用于解密token
String remoteAddr = request.getHeader("x-forwarded-for");
// 认证方法在passportController中,需要使用远程调用认证 后边会贴代码
String result = HttpClientUtil.doGet(WebConst.VERIFY_ADDRESS + "?token=" + token + "¤tIp=" + remoteAddr);
if ("success".equals(result)){
// 成功,从token中解密用户信息(用户id和用户昵称)
Map map = getUserMapByToken(token);
String userId = (String) map.get("userId");
//保存userId到request域中,很重要,用于需要登陆才能访问的控制器取得用户信息!
request.setAttribute("userId",userId);
return true;
}else {
if (methodAnnotation.autoRedirect()){
//自定义注解可设置为需要登陆访问 也可设置为不需要登陆
//如果设置了需要登陆 但是认证没有通过就需要重定向到登陆页面
redirectLogin(request,response);
return false;
}
}
}
return true;
}
//重定向到登陆界面
private void redirectLogin(HttpServletRequest request, HttpServletResponse response) throws IOException {
String requestURL = request.getRequestURL().toString();
String encodeURL = URLEncoder.encode(requestURL, "UTF-8");
response.sendRedirect(WebConst.LOGIN_ADDRESS+"?originUrl="+encodeURL);
}
//从token中解密用户信息(用户id和用户昵称)
private Map getUserMapByToken(String token) {
String tokenUserInfo = StringUtils.substringBetween(token, ".");
Base64UrlCodec base64UrlCodec = new Base64UrlCodec();
byte[] decode = base64UrlCodec.decode(tokenUserInfo);
String tokenJson = null;
try{
tokenJson = new String(decode,"UTF-8");
}catch(Exception e){
e.printStackTrace();
}
Map map = JSON.parseObject(tokenJson, Map.class);
return map;
}
}
自定义注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequire {
boolean autoRedirect() default true;
}
远程登陆认证服务控制器:其实就是解密token然后到redis中根据解密得到的用户id查找是否已经在缓存中,如果在就说明已经登陆了,不在就说明还没有登陆,或者登陆已经过期,需要重新登陆
登陆认证的控制器"/verify"
//登录验证
@RequestMapping("/verify")
@ResponseBody
public String verify(HttpServletRequest request){
String token = request.getParameter("token");
String currentIp = request.getParameter("currentIp");
//用JWT工具类解密token
Map<String, Object> map = JwtUtil.decode(token, signKey, currentIp);
if(map!=null&&map.size()>0){
String userId = (String) map.get("userId");
//拿到后台redis中认证
UserInfo userInfo = userInfoService.verify(userId);
if(userInfo!=null){
return "success";
}
}
return "fail";
}
后台认证代码:前面已经有了,这里再贴一次
//根据userId进行登陆认证
public UserInfo verify(String userId){
//去缓存中查询是否有对应userId的用户信息
Jedis jedis = redisUtil.getJedis();
String key = userKey_prefix+userId+userinfoKey_suffix;
String userInfoJson = jedis.get(key);
if(userInfoJson!=null&&userInfoJson.trim().length()>0){
//给这个key重新设置失效时间
jedis.expire(key, userKey_timeOut);
UserInfo userInfo = JSON.parseObject(userInfoJson, UserInfo.class);
return userInfo;
}
return null;
}
至此,一个完整的单点登陆(SSO)已经实现,并且在别的需要登陆才能访问的控制器上,直接加上@LoginRequire(autoRedirect=true)注解即可.\
只要通过登陆认证了,就可以直接在控制器方法里从request域中获取用户的userId
后边两个需求在之后的电商购物车模块中完成
④当在未登录状态下进行购物车的结算时,需要先跳转到登陆页面,登陆后不必回退到购物车列表,直接进入订单页面,
⑤当在未登录状态下的购物车列表,点击登陆进行登陆时,应该进行购物车的合并,并回到原来的购物车列表页面