1.什么情况下需要登录操作?

  首先抛出一个问题,什么情况下才需要登录操作,其实登录操作在很多的管理系统,后台系统中都会涉及到的一个看似简单,但是又特别重要的操作

2.登录是简单的验证数据库账号密码,这么简单吗?

  在之前我总觉得登录应该是一个很简单的操作,验证数据库?然后通过.但是这样做的一个简单的判断,能完成登录操作,但是?我能不能绕过你的登录呢?答案是可以的.我最开始可以不调用你的登录接口,我直接调用你的后台其他接口,就能实现绕过验证,进行操作你的管理系统.这样你的登录操作对我来说,形同虚设.

3.有没有办法能让使用者,在用我的管理系统之间必须要进行登录操作呢?

  答案是有的,这里就引出了一个概念:拦截,拦截是什么?拦截简单点说就是做一个拦截操作,不满足我的条件时,你无法访问我设计的一些接口.所以这里,我们需要了解一下拦截器: Interceptor

4.什么是拦截器?

这里的拦截器讲得是mvc中的拦截器,后面小例子会用一个springboot项目作为演示.这里用到的拦截器类似于servlet中的Filter过滤器.用于拦截用户的请求,进行一些权限验证,登录等操作

5.如何定义一个intercept拦截器呢?

首先定义一个拦截器,有四种方法:

  1. 实现Spring的HandlerInterceptor接口;
  2. 继承实现了HandlerInterceptor接口的类,比如Spring 已经提供的实现了HandlerInterceptor 接口的抽象类HandlerInterceptorAdapter;
  3. 实现Spring的WebRequestInterceptor接口;
  4. 继承实现了WebRequestInterceptor的类;

这里我们通过第二种方式来实现我们登录操作:

public class LoginInterceptor extends HandlerInterceptorAdapter

我们来分析一下HandlerInterceptorAdapter

package org.springframework.web.servlet.handler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

public abstract class HandlerInterceptorAdapter implements AsyncHandlerInterceptor {
    public HandlerInterceptorAdapter() {
    }

    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return true;
    }

    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
    }

    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
    }

在这个源码中我们可以看到HandlerInterceptorAdapter实现了AsyncHandlerInterceptor接口的三个方法,分别是preHandle postHandle afterCompletion,
首先分析第一个方法:
preHandle() :预处理回调方法,若方法返回值为true,请求继续(调用下一个拦截器或处理器方法);若方法返回值为false,请求处理流程中断,不会继续调用其他的拦截器或处理器方法,此时需要通过response产生响应;

简单点说就是:在每次调用需要拦截器验证的接口时,会预先调用这个preHandler方法
用一个小例子来说明:

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        Anonymous anonymous = (handler instanceof HandlerMethod) ?
                ((HandlerMethod) handler).getMethodAnnotation(Anonymous.class) : null;
        if (null != anonymous && anonymous.value()) {
            return true;
        }

        String token = cookieUtil.getValue(request, CookieUtil.TOKEN);
        RequestAdmin.Admin admin = adminManageService.identify(token);
        if(null == admin) {
            throw new BizException(SystemCode.NEED_LOGIN);
        }

        //延长cookie
        cookieUtil.setToken(request, response, token);
        RequestAdmin.put(admin);

        return true;
    }

说明:这里实现登录用了一个Anonymous,我们可以理解把这个理解为访客模式,也就是说在执行某个接口的时候,可以不需要验证,直接通过拦截器.当然,这里需要在方法上加上一个自定义的anonymous注解,接着我们看下面,通过一个cookie工具类或者cookie的值,工具类具体代码如下:

@Configuration
public class CookieUtil {


    @Value("${manage.cookie.domain}")
    private String domain;

    @Value("${manage.cookie.maxAge}")
    private Integer maxAge;

    public static final String TOKEN = "token";

    public void setToken(HttpServletRequest request, HttpServletResponse response, String cookieValue) {
        setCookie(request, response, TOKEN, cookieValue, "/", maxAge);
    }

    public void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
            String cookieValue, String path, int maxAge) {
        try {
            Cookie cookie = new Cookie(cookieName, URLEncoder.encode(cookieValue, "utf-8"));
            cookie.setMaxAge(maxAge);
            cookie.setPath(path);
            cookie.setDomain(domain);
            cookie.setHttpOnly(true);
            response.addCookie(cookie);
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取cookie值
     *
     * @param request
     * @param name
     * @return
     */
    public String getValue(HttpServletRequest request, String name) {
        String value = null;
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (name.equals(cookie.getName())) {
                    try {
                        value = URLDecoder.decode(cookie.getValue(), "utf-8");
                    } catch (UnsupportedEncodingException e) {
                        throw new RuntimeException(e.getMessage());
                    }
                    break;
                }
            }
        }
        return value;
    }
}

通过调用这个方法,从浏览器请求中获取到浏览器携带过来的cookie,然后将这个cookie通过设置的cookie前缀拿出来,这里是key-value结构.获取到这个cookie信息之后我们调用方式去识别这个cookie信息是否正确,为什么要这一步呢?,因为浏览器的cookie是多种多样的,我们要识别这个携带过来的cookie是不是我们在登录时设置的那个,而不是cookie有值就可以了.同样这里也通过这个方法获取到了我们放进入的用户信息

getValue方法:

public String getValue(HttpServletRequest request, String name) {
        String value = null;
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (name.equals(cookie.getName())) {
                    try {
                        value = URLDecoder.decode(cookie.getValue(), "utf-8");
                    } catch (UnsupportedEncodingException e) {
                        throw new RuntimeException(e.getMessage());
                    }
                    break;
                }
            }
        }
        return value;
    }

这里拿到请求带过来的cookie,进行一个遍历操作,然后获取key相同的那个,也就是name相同的,拿着这个cookie进行解码操作value = URLDecoder.decode(cookie.getValue(), "utf-8");,解码完成后退出循环,这样就保证拿到了解码后的cookie值.

接着我们拿到了从cookie解码后的值设置为token,这里因为项目后面需要记录这个登陆者的信息,所以我们要用一个ThreadLocal存储这个登录人的信息,也就是代码中调用的identify方法,这个方法中:

/**
     * 添加全局请求辅助类
     *
     * @param token 用户信息
     * @return admin
     */
    @Override
    public RequestAdmin.Admin identify(String token) {
        if (StringUtils.isBlank(token)) {
            return null;
        }
        Admin admin = cacheClient.get(CachePrefix.TOKEN, token);
        if (null == admin) {
            return null;
        }
        cacheClient.increment(CachePrefix.TOKEN, token, 60 * 60L);
        RequestAdmin.Admin requestAdmin = new RequestAdmin.Admin();
        requestAdmin.setId(admin.getId());
        if (StringUtils.isNotBlank(admin.getUserName())) {
            requestAdmin.setUserName(admin.getUserName());
        }
        if (StringUtils.isNotBlank(admin.getPassword())) {
            requestAdmin.setPassword(admin.getPassword());
        }
        return requestAdmin;
    }

上面代码的操作就是获取到一个全局都能调用的信息:比如说我要在其他接口上用到这个登录者的信息,那我不需要调用数据库去查询,只要设置这个全局请求类,后面调用他就可以了

做完上面的操作后,然后延长cookie的时间,也就是说每次请求都会延长cookie的时间.

这就是preHandler方法做的事,如果验证通过的则可以进入下一步了

postHandle():后处理回调方法,实现处理器的后处理(但在渲染视图之前),此时可以通过modelAndView对模型数据进行处理或对视图进行处理;(解释:对接口中的数据进行处理,这个时候并没有会回调参数给前端页面上),我做的这个springboot项目是前后分离的,所以在这个方法中就没有做操作

afterCompletion():整个请求处理完毕回调方法,即在视图渲染完毕时调用;
这里的话其实一般就是在调用接口完成后进行一个全局信息的清理.

上面步骤中,只是说到了拦截器起到的作用,但是比没有解释上面的cookie,token,redis怎么设置的,在拦截器中只是做了一个判断是否存在.

回到正题:怎么实现登录操作:

在上面我们一直解释拦截器怎么去保证后面接口的一个安全性.接下俩我们说说登录的接口中应该做什么?

让我们看一段实际项目的代码:

public Boolean login(String userName, String password, HttpServletRequest request, HttpServletResponse response) {

        if (StringUtils.isBlank(userName) || StringUtils.isBlank(password)) {
            log.error("AdminManageServiceImpl --- login 登录账号信息异常 userName = {},password = {}",userName,password);
            throw new BizException(BizErrorCode.ACCOUNT_ERROR);
        }


        Admin admin = adminService.getAdmin(userName);

        if (null == admin) {
            log.error("AdminManageServiceImpl --- login 获取admin信息异常 userName = {}",userName);
            throw new BizException(BizErrorCode.ACCOUNT_NOT_EXIST);
        }

        if (!admin.getPassword().equals(MD5.encrypt(password))) {
            log.error("AdminManageServiceImpl --- login 获取admin信息异常 password = {}",password);
            throw new BizException(BizErrorCode.ACCOUNT_PASSWORD_ERROR);
        }

        //存储token信息
        String token = EncryptUtils.md5(UUID.randomUUID() + admin.getId().toString() + System.currentTimeMillis());

        AdminToken adminToken = adminTokenService.getAdminToken(admin.getAdminId());

        if (null == adminToken) {
            AdminToken tokenInfo = new AdminToken();
            tokenInfo.setAdminId(admin.getAdminId());
            tokenInfo.setToken(token);

            boolean b = adminTokenService.saveAdminToken(tokenInfo);
            System.out.println(b);


        } else {
            //删除在redis上的,然后把新的设置进入
            cacheClient.delete(CachePrefix.TOKEN, adminToken.getToken());

            adminToken.setToken(token);
            adminTokenService.updateAdminToken(adminToken);
        }

        //设计token缓存信息
        cacheClient.set(CachePrefix.TOKEN, token, admin, 60 * 60L);

        if (null == admin.getUserName()){
            log.info("AdminManageServiceImpl --- login 获取admin中的用户名失败 userName = {}",userName);
            throw new BizException(BizErrorCode.ACCOUNT_ERROR);
        }
        String token1 = getToken(userName);
        cookieUtil.setToken(request,response,token1);
        return admin.getUserName() != null;
    }

解释一下这段代码:
  首先我们获取到前端输入的用户名和密码之后,首先要做的就是对这个用户名和密码进行非空验证,验证通过之后在进行一个查询数据库中用户表信息的操作,同时也要对查询来的用户对象进行非空判断,为什么要做一步呢?,很多人在想,如果说我都能查出来的,那为什么还需要这一步验证呢?
  解释:由于我们查询条件是按照用户名进行查询,不排除查数据库中有用户名但是没有密码,或者说含有逻辑删除(逻辑删除:这个mybatisPlus中做的一个功能,执行删除操作时,并不会真正的删除这条信息,只是改变该条信息的状态位),这就是异常处理,一个完整程序必须要做的.

  接着我们设定一条存储信息,也就是设置一个新的token,然后我们通过登录者的id去查询token表中对应的token(旧的token).如果说根据id没有查到token时,就将存储这个新的token.
如果这个token不是空的,也就是会出现多个人登录同一个账号的情况下,就删除redis中对应储存的token,然后更新数据库中的这个旧的token.

接着设计redis中新的一个这个token,并设置过期时间,并通过cookie工具类去设置cookie信息,并在这个cookie信息中设置最大过期时间.

这样我们在redis中设置了过期时间,同时在cookie中设置了过期时间.

6.那怎么实现登出操作呢?

@Override
    public Boolean loginOut() {
        AdminToken token = adminTokenService.getAdminToken(RequestAdmin.getId());
        cacheClient.delete(CachePrefix.TOKEN,token.getToken());
        boolean status = adminTokenService.removeById(RequestAdmin.getId());
        if (!status){
            log.info("AdminManageServiceImpl --- loginOut 获取token失败 token = {}",token);
            throw new BizException(BizErrorCode.LOGOUT_ERROR);
        }
        return true;
    }

很简单,在我们登出的时候,删除redis中的token记录,同时删掉数据库中的token.

以上就是实现登录操作的思路,如果有问题可以下方留言,文章有不足之处,还请指正.