ruoyi-Vue 登陆解析

前置:
阅读本篇前请阅读《Spring Security 解析

我们在《Spring Security 解析 》这一章节曾经说过SpringSecurity的登陆流程以及结合JWT使用来完成相关的登陆功能。
这里也顺带再总结一下流程:
登陆:

  1. 用户进行登录操作,传递账号密码过来 登录接口调用AuthenticationManager
  2. 根据用户名查询出用户数据 UserDetailService查询出UserDetails
  3. 将传递过来的密码和数据库中的密码进行对比校验 PasswordEncoder
  4. 校验通过则将认证信息存入到上下文中 将UserDetails存入到Authentication,将Authentication存入到SecurityContext
  5. 如果认证失败则抛出异常 由AuthenticationEntryPoint处理

JWT:

  1. 登陆生成token返回给前端,前端访问其他接口携带token
  2. 自定义认证过滤器,来对token进行校验
  3. 将自定义的认证过滤器添加至默认的认证过滤器(UsernamePasswordAuthenticationFilter),如果没有启用表单认证,UsernamePasswordAuthenticationFilter将会被剔除出过滤链

我们正式开始查看ruoyi是怎么实现登陆流程的。

ruoyi

ruoyi是结合JWT来进行相关登陆认证的,所以可以参考之前的JWT过滤器来学习如何使用登陆。

从前端页面获取相关登陆地址

image.png
在当前登陆页面,我们进行一个登陆按钮的确认,发现这个请求会被/dev-api/login所处理,那很简单,/dev-api是前端所处理的路由地址,我们直接去后端里找请求路径带/login的方法,
image.png

login方法

按住ctrl + shift + F,我们可以很轻松的定位到SysLoginController里面的login方法


    /**
     * 登录方法
     * 
     * @param loginBody 登录信息
     * @return 结果
     */
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody)
    {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
                loginBody.getUuid());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }

loginService.login

怎么跟我们之前说的登录方案不一样,并没有看到SecurityContext跟其他的SpringSecurity组件操作,别急,我们进入loginService.login这个方法一探究竟。

/**
     * 登录验证
     * 
     * @param username 用户名
     * @param password 密码
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public String login(String username, String password, String code, String uuid)
    {
        // wonderc 从数据库中获取验证码是否打开
        boolean captchaEnabled = configService.selectCaptchaEnabled();
        // 验证码开关
        if (captchaEnabled)
        {
            validateCaptcha(username, code, uuid);
        }
        // 用户验证
        // wonderc 熟悉的老朋友- authentication
        Authentication authentication = null;
        try
        {
            //wonderc 可以看到在这里就开始我们之前所说的登录流程
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            // 这个是干嘛用的呢,ruoyi自己的上下文对象将这个对象进行存储
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                //wonderc 新开线程捕获异常 记录日志(后面再说)
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                //wonderc 新开线程捕获异常 记录日志(后面再说)
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {	
            
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

我们可以看到,ruoyi在 loginService.login里面包含了我们之前所说的登陆流程。
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);将用户名与密码进行传入
AuthenticationContextHolder.setContext(authenticationToken);这里ruoyi实现了自己的上下文对象,我们暂时先不说,只用知道它存储了这个对象进自己的上下文对象。
authentication = authenticationManager.authenticate(authenticationToken);
调AuthenticationManager 的相关方法,这个方法会去根据用户名查询出用户对象,那这个方法是怎么根据用户名查询出用户对象的呢,这个就要看UserDetialsService接口了,这个接口只有一个方法 loadUserByUsername(String username),通过用户名查询用户对象。

UserDetialsService 接口

ruoyi实现了自己UserDetialsService接口,实现自己的业务方法。

package com.ruoyi.framework.web.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.enums.UserStatus;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysUserService;

/**
 * 用户验证处理
 *
 * @author ruoyi
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    @Autowired
    private ISysUserService userService;
    
    @Autowired
    private SysPasswordService passwordService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        //根据用户名校验
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user))
        {
            log.info("登录用户:{} 不存在.", username);
            throw new ServiceException("登录用户:" + username + " 不存在");
        }
        else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
        {
            log.info("登录用户:{} 已被删除.", username);
            throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
        }
        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
        {
            log.info("登录用户:{} 已被停用.", username);
            throw new ServiceException("对不起,您的账号:" + username + " 已停用");
        }
    	//密码校验
        passwordService.validate(user);
    	// 创建用户凭证
        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user)
    {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }
}

SysUser user = userService.selectUserByUserName(username);这串代码找到相关用户,然后进行一系类校验,判断是否当前用户是否正常可使用。
然后是密码校验,我们来关注下 passwordService.validate(user);

public void validate(SysUser user)
    {
        //ruoyi 自己封装的上下文对象
        Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
        String username = usernamePasswordAuthenticationToken.getName();
        String password = usernamePasswordAuthenticationToken.getCredentials().toString();
    	//重试次数
        Integer retryCount = redisCache.getCacheObject(getCacheKey(username));

        if (retryCount == null)
        {
            retryCount = 0;
        }

        if (retryCount >= Integer.valueOf(maxRetryCount).intValue())
        {
            //异步记录日志 这里是说密码重试次数大于最大次数
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL,
                    MessageUtils.message("user.password.retry.limit.exceed", maxRetryCount, lockTime)));
            throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
        }

		// 看密码是否匹配
        if (!matches(user, password))
        {
            // 密码不匹配,重试次数+1
            retryCount = retryCount + 1;
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL,
                    MessageUtils.message("user.password.retry.limit.count", retryCount)));
            //redis进行相关重试次数的存储
            redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);
            throw new UserPasswordNotMatchException();
        }
        else
        {
             clearLoginRecordCache(username);
        }
    }

我们再来看看,matches(user, password)方法,最底层是调用matchesPassword这个方法。

/**
 * 判断密码是否相同
 *
 * @param rawPassword 真实密码
 * @param encodedPassword 加密后字符
 * @return 结果
 */
public static boolean matchesPassword(String rawPassword, String encodedPassword)
{
    
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    return passwordEncoder.matches(rawPassword, encodedPassword);
} 

这里怎么会调用 BCryptPasswordEncoder进行校验,那这样岂不是跟SpringSecurity自身的密码编码器
不一样,我们看看SpringSecurity的配置类,看看是不是已经配置这个密码编码器。

/**
 * spring security配置
 * 
 * @author ruoyi
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
    ------------------忽略代码
    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

我们看配置文件,果然是配置了BCryptPasswordEncoder这个密码编码器。那这里就是在matches方法做了一次密码的校验,也就是loadUserByUsername方法中进行了一次密码的校验。
我们再回过头来看UserDetailsServiceImpl,还有一个方法 createLoginUser,按照我们在SpringSecurity学到的,这里会返回一个UserDetails对象。

 public UserDetails createLoginUser(SysUser user)
    {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }

当我们拿到这个UserDetails对象,就会返回到login方法,UserDetails封装进authentication对象,返回到方法调用方。

createToken

我们再看回一开始的login方法,我们已经返回了认证过的authentication对象

/**
* 登录验证
* 
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid)
    {
    boolean captchaEnabled = configService.selectCaptchaEnabled();
    // 验证码开关
    if (captchaEnabled)
    {
        validateCaptcha(username, code, uuid);
    }
    // 用户验证
    Authentication authentication = null;
    try
        {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
            //方法调用完  UserDetails 封装到 authentication对象
        }
    catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
    finally
        {
            AuthenticationContextHolder.clearContext();
        }
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    //刷新用户相关信息
    recordLoginInfo(loginUser.getUserId());
    // 生成token
    return tokenService.createToken(loginUser);
}

我们往下看,
LoginUser loginUser = (LoginUser) authentication.getPrincipal();这里是拿到了已认证用户的信息
我们重点看看这个方法tokenService.createToken(loginUser);

/**
 * 令牌前缀
 */
public static final String LOGIN_USER_KEY = "login_user_key";

/**
 * 创建令牌
 *
 * @param loginUser 用户信息
 * @return 令牌
 */
public String createToken(LoginUser loginUser)
{
    String token = IdUtils.fastUUID();
    loginUser.setToken(token);
    // 设置loginUser的相关代理信息
    setUserAgent(loginUser);
    // 刷新令牌 - 存储令牌
    refreshToken(loginUser);
    Map<String, Object> claims = new HashMap<>();
    claims.put(Constants.LOGIN_USER_KEY, token);
	//利用JWT 生成token
    return createToken(claims);
}
/**
 * 设置用户代理信息
 *
 * @param loginUser 登录信息
 */
public void setUserAgent(LoginUser loginUser)
{
    UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
    String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
    loginUser.setIpaddr(ip);
    loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
    loginUser.setBrowser(userAgent.getBrowser().getName());
    loginUser.setOs(userAgent.getOperatingSystem().getName());
}
// 令牌有效期(默认30分钟)
@Value("${token.expireTime}")
private int expireTime;
/**
 * 刷新令牌有效期
 *
 * @param loginUser 登录信息
 */
public void refreshToken(LoginUser loginUser)
{
    loginUser.setLoginTime(System.currentTimeMillis());
    loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
    // 根据uuid将loginUser缓存
    String userKey = getTokenKey(loginUser.getToken());
	// 将本次login的用户进行存储至redis中
    redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
 
private String getTokenKey(String uuid)
{
    // public static final String LOGIN_USER_KEY = "login_user_key";
    return CacheConstants.LOGIN_TOKEN_KEY + uuid;
}


/**
     * 从数据声明生成令牌
     *
     * @param claims 数据声明
     * @return 令牌
     */
    private String createToken(Map<String, Object> claims)
    {
        String token = Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
        return token;
    }

我们看这一串代码下来,发现生成了一个UUID,同时用这个UUID在redis中生成对象,并且生成了一个JWT token返回到方法调用处,然后就直接返回给前端了。
前端将这块的token存入到了cookie中,后续发起请求都会携带这个token,我们按照之前学的SpringSecurity结合JWT可以知道,这块应该有个过滤器,专门对token做处理。

JwtAuthenticationTokenFilter

在教学SpringSecurity的时候说过,SpringSecurity的本质是一条过滤器链。ruoyi是在JwtAuthenticationTokenFilter中与JWT结合的。每一次后端被请求时,都会执行 JwtAuthenticationTokenFilter 过滤器,该过滤器是在从请求 request 的请求头(key 为 Authorization 的条目)中获得 token,然后验证 token,验证成功则直接封装一个已经成功认证的信息 authenticationToken,并存在上下文中。相关代码如下:

package com.ruoyi.framework.security.filter;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.TokenService;

/**
 * token过滤器 验证token有效性
 * 
 * @author ruoyi
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        //通过cookie,拿到当前登陆用户
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            //验证令牌,自动刷新数据库缓存
            tokenService.verifyToken(loginUser);
            //将当前已验证用户放入到上下文对象中
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

通过token获取到我们登陆时生成的UUID,通过该uuid,拿到已经认证成功存入redis中的当前用户对象。

 /**
     * 获取用户身份信息
     *
     * @return 用户信息
     */
    public LoginUser getLoginUser(HttpServletRequest request)
    {
        // 获取请求携带的令牌
        String token = getToken(request);
        if (StringUtils.isNotEmpty(token))
        {
            try
            {
                Claims claims = parseToken(token);
                // 解析对应的权限以及用户信息
                String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
                String userKey = getTokenKey(uuid);
                LoginUser user = redisCache.getCacheObject(userKey);
                return user;
            }
            catch (Exception e)
            {
            }
        }
        return null;
    }

tokenService.verifyToken(loginUser);

/**
 * 验证令牌有效期,相差不足20分钟,自动刷新缓存
 *
 * @param loginUser
 * @return 令牌
 */
public void verifyToken(LoginUser loginUser)
{
    long expireTime = loginUser.getExpireTime();
    long currentTime = System.currentTimeMillis();
    if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
    {
        refreshToken(loginUser);
    }
}

总结

我们总结一下ruoyi的登陆流程,首先是登陆,系统获取到用户的账号密码,然后进行校验,校验完成,则将已认证对象存入redis中,同时返回token,后续请求,前端都会带上token,后端用JwtAuthenticationTokenFilter进行校验,如果校验通过则将当前已认证对象在redis中取出,同时将这个已认证对象存入SpringSecurity的上下文对象中。

posted @ 2023-02-16 15:09  WonderC  阅读(954)  评论(0编辑  收藏  举报