peteryuanpan / notebook

喜欢的,值得留念的,就记下来,总会有用的。
73 stars 43 forks source link

Github:macrozheng/mall项目学习总结 #141

Closed peteryuanpan closed 3 years ago

peteryuanpan commented 4 years ago

Github:https://github.com/macrozheng/mall mall项目是一套电商系统,包括前台商城系统及后台管理系统,基于SpringBoot+MyBatis实现,采用Docker容器化部署。 前台商城系统包含首页门户、商品推荐、商品搜索、商品展示、购物车、订单流程、会员中心、客户服务、帮助中心等模块。 后台管理系统包含商品管理、订单管理、会员管理、促销管理、运营管理、内容管理、统计报表、财务管理、权限管理、设置等模块

Github:https://github.com/macrozheng/mall-admin-web mall-admin-web是一个电商后台管理系统的前端项目,基于Vue+Element实现。 主要包括商品管理、订单管理、会员管理、促销管理、运营管理、内容管理、统计报表、财务管理、权限管理、设置等功能

Github:https://github.com/macrozheng/mall-swarm mall-swarm是一套微服务商城系统,采用了 Spring Cloud Hoxton & Alibaba、Spring Boot 2.3、Oauth2、MyBatis、Docker、Elasticsearch等核心技术,同时提供了基于Vue的管理后台方便快速搭建系统。mall-swarm在电商业务的基础集成了注册中心、配置中心、监控中心、网关等系统功能。文档齐全,附带全套Spring Cloud教程

Github:https://github.com/macrozheng/springcloud-learning 一套涵盖大部分核心组件使用的Spring Cloud教程,包括Spring Cloud Alibaba及分布式事务Seata,基于Spring Cloud Greenwich及SpringBoot 2.1.7。21篇文章,篇篇精华,32个Demo,涵盖大部分应用场景

Github:https://github.com/macrozheng/mall-learning 项目中文文档:http://www.macrozheng.com/#/README

peteryuanpan commented 4 years ago

按照文档中的搭建步骤,由于以前有了经验,比较顺利

搭建步骤
Windows环境部署

Windows环境搭建请参考:mall在Windows环境下的部署;
注意:只启动mall-admin,仅需安装Mysql、Redis即可;
克隆mall-admin-web项目,并导入到IDEA中完成编译:前端项目地址;
mall-admin-web项目的安装及部署请参考:mall前端项目的安装与部署。

mall-admin

peteryuanpan commented 4 years ago

本项目的 SecurityConfig 中 configure(HttpSecurity httpSecurity) 是采用静态白名单配置的方法

SecurityConfig

public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired(required = false)
    private DynamicSecurityService dynamicSecurityService;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity
                .authorizeRequests();
        //不需要保护的资源路径允许访问
        for (String url : ignoreUrlsConfig().getUrls()) {
            registry.antMatchers(url).permitAll();
        }
...
    }

    @Bean
    public IgnoreUrlsConfig ignoreUrlsConfig() {
        return new IgnoreUrlsConfig();
    }
...
}

IgnoreUrlsConfig

package com.macro.mall.security.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.ArrayList;
import java.util.List;

/**
 * 用于配置白名单资源路径
 * Created by macro on 2018/11/5.
 */
@Getter
@Setter
@ConfigurationProperties(prefix = "secure.ignored")
public class IgnoreUrlsConfig {

    private List<String> urls = new ArrayList<>();

    public List<String> getUrls() {
        return urls;
    }
}

application.yml

secure:
  ignored:
    urls: #安全路径白名单
      - /swagger-ui.html
      - /swagger-resources/**
      - /swagger/**
      - /**/v2/api-docs
      - /**/*.js
      - /**/*.css
      - /**/*.png
      - /**/*.ico
      - /webjars/springfox-swagger-ui/**
      - /actuator/**
      - /druid/**
      - /admin/login
      - /admin/register
      - /admin/info
      - /admin/logout
      - /minio/upload
peteryuanpan commented 4 years ago

一站式登陆功能分析(一)

登陆后,前端给后端发请求,后端是如何识别不同的用户,并进行处理的呢?

带着这个问题,我看了下代码,发现了JWT登录支持技术,它的github是:https://github.com/jwtk/jjwt

这个问题分别几个点

下面我们来分析一下项目代码

@Controller
@Api(tags = "UmsAdminController", description = "后台用户管理")
@RequestMapping("/admin")
public class UmsAdminController {
    @Autowired
    private UmsAdminService adminService;

    @ApiOperation(value = "登录以后返回token")
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public CommonResult login(@Validated @RequestBody UmsAdminLoginParam umsAdminLoginParam) {
        String token = adminService.login(umsAdminLoginParam.getUsername(), umsAdminLoginParam.getPassword());
        if (token == null) {
            return CommonResult.validateFailed("用户名或密码错误");
        }
        Map<String, String> tokenMap = new HashMap<>();
        tokenMap.put("token", token);
        tokenMap.put("tokenHead", tokenHead);
        return CommonResult.success(tokenMap);
    }
}

上面是 /login 接口的 Controller 代码,可以看出请求参数中是包含 username 的

UmsAdminController 类有一个属性 UmsAdminService adminService; 这个属性的具体实现如下

@Service
public class UmsAdminServiceImpl implements UmsAdminService {
    @Override
    public String login(String username, String password) {
        String token = null;
        //密码需要客户端加密后传递
        try {
            UserDetails userDetails = loadUserByUsername(username);
            if(!passwordEncoder.matches(password,userDetails.getPassword())){
                Asserts.fail("密码不正确");
            }
            if(!userDetails.isEnabled()){
                Asserts.fail("帐号已被禁用");
            }
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
            token = jwtTokenUtil.generateToken(userDetails);
//            updateLoginTimeByUsername(username);
            insertLoginLog(username);
        } catch (AuthenticationException e) {
            LOGGER.warn("登录异常:{}", e.getMessage());
        }
        return token;
    }
}

上面这里有一句话:token = jwtTokenUtil.generateToken(userDetails); 表示生成 token,详细代码如下

public class JwtTokenUtil {
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
        claims.put(CLAIM_KEY_CREATED, new Date());
        return generateToken(claims);
    }
    private String generateToken(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate())
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
}

可以看出上面的代码中有一句 userDetails.getUsername(),即 token 中是包含 username 的,也可以反向解析出来

上面这里就用到了 Jwts 了,github 的 quickStart https://github.com/jwtk/jjwt#quickstart 中也是这么建议实现的,如下

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.security.Key;

// We need a signing key, so we'll create one just for this example. Usually
// the key would be read from your application configuration instead.
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

String jws = Jwts.builder().setSubject("Joe").signWith(key).compact();

好,到这里可以小结一下,UmsAdminController 中 login 方法负责处理 /login 接口,参数是 username、password,我们需要返回一个 tokenMap,存储的是 json 类型,其中参数有 token、tokenHead

来看一下前端的接口参数,可以看出 Response 有 token 了,token=A.B.C 是 JWT token 的形式

Request

Request URL: http://localhost:8080/admin/login
Request Method: POST
Status Code: 200 
Remote Address: [::1]:8080
{"username":"admin","password":"macro123"}

Response

{"code":200,"message":"操作成功","data":{"tokenHead":"Bearer ","token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImNyZWF0ZWQiOjE2MDU2ODcwMzQwMjAsImV4cCI6MTYwNjI5MTgzNH0.FnrIMdNbp0kmqhwgQIvwIvRvzIbSOASEvj1vyADGZdsBsM-JKB6DmuzyP-tLPFnQXgmsh_X4MjU-HAmC-RcSkg"}}

后端的处理有多个操作

以切面类来说

@Aspect
@Component
@Order(1)
public class WebLogAspect {
    @Pointcut("execution(public * com.macro.mall.controller.*.*(..))||execution(public * com.macro.mall.*.controller.*.*(..))")
    public void webLog() {
    }

    @Around("webLog()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        //获取当前请求对象
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        //记录请求信息(通过Logstash传入Elasticsearch)
        WebLog webLog = new WebLog();
        Object result = joinPoint.proceed();
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        if (method.isAnnotationPresent(ApiOperation.class)) {
            ApiOperation log = method.getAnnotation(ApiOperation.class);
            webLog.setDescription(log.value());
        }
        long endTime = System.currentTimeMillis();
        String urlStr = request.getRequestURL().toString();
        webLog.setBasePath(StrUtil.removeSuffix(urlStr, URLUtil.url(urlStr).getPath()));
        webLog.setIp(request.getRemoteUser());
        webLog.setMethod(request.getMethod());
        webLog.setParameter(getParameter(method, joinPoint.getArgs()));
        webLog.setResult(result);
        webLog.setSpendTime((int) (endTime - startTime));
        webLog.setStartTime(startTime);
        webLog.setUri(request.getRequestURI());
        webLog.setUrl(request.getRequestURL().toString());
        Map<String,Object> logMap = new HashMap<>();
        logMap.put("url",webLog.getUrl());
        logMap.put("method",webLog.getMethod());
        logMap.put("parameter",webLog.getParameter());
        logMap.put("spendTime",webLog.getSpendTime());
        logMap.put("description",webLog.getDescription());
//        LOGGER.info("{}", JSONUtil.parse(webLog));
        LOGGER.info(Markers.appendEntries(logMap), JSONUtil.parse(webLog).toString());
        return result;
    }
}

这个切面类是针对所有 Controller(execution(public com.macro.mall..controller..(..)))切面,打印其日志

打印的日志例子如下

2020-11-18 12:15:04.302  INFO 10156 --- [nio-8080-exec-1] com.macro.mall.common.log.WebLogAspect   : {"method":"GET","ip":"admin","description":"根据品牌名称分页获取品牌列表","uri":"/brand/list","url":"http://localhost:8080/brand/list","result":{"code":200,"data":{"totalPage":1,"pageSize":100,"list":[{"productCommentCount":100,"showStatus":1,"sort":500,"productCount":100,"factoryStatus":1,"name":"小米","bigPic":"http://macro-oss.oss-cn-shenzhen.aliyuncs.com/mall/images/20180518/5afd7778Nf7800b75.jpg","logo":"http://macro-oss.oss-cn-shenzhen.aliyuncs.com/mall/images/20180518/5a912944N474afb7a.png","id":6,"firstLetter":"M"},...

日志中有 "ip":"admin",它就是登陆的 username,而切面类中设置它的代码是 webLog.setIp(request.getRemoteUser());

debug step into 发现,request 实例是 SecurityContextHolderAwareRequestWrapper,而 getRemoteUser方法如下

public class SecurityContextHolderAwareRequestWrapper extends HttpServletRequestWrapper 
    @Override
    public String getRemoteUser() {
        Authentication auth = getAuthentication();

        if ((auth == null) || (auth.getPrincipal() == null)) {
            return null;
        }

        if (auth.getPrincipal() instanceof UserDetails) {
            return ((UserDetails) auth.getPrincipal()).getUsername();
        }

        return auth.getPrincipal().toString();
    }
}

其中有一个 Authentication auth = getAuthentication(); 是通过 ((UserDetails) auth.getPrincipal()).getUsername(); 获取 username

再来看一下JWT登录授权过滤器

public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity
                .authorizeRequests();
        //不需要保护的资源路径允许访问
        for (String url : ignoreUrlsConfig().getUrls()) {
            registry.antMatchers(url).permitAll();
        }
        //允许跨域请求的OPTIONS请求
        registry.antMatchers(HttpMethod.OPTIONS)
                .permitAll();
        // 任何请求需要身份认证
        registry.and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                // 关闭跨站请求防护及不使用session
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 自定义权限拒绝处理类
                .and()
                .exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler())
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                // 自定义权限拦截器JWT过滤器
                .and()
                .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        //有动态权限配置时添加动态权限校验过滤器
        if (dynamicSecurityService != null) {
            registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
        }
    }

    @Bean
    public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
        return new JwtAuthenticationTokenFilter();
    }
}

SecurityConfig 中定义了一个 filter jwtAuthenticationTokenFilter

public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.tokenHeader);
        if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
            String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
            LOGGER.debug("authToken:{}", authToken);
            String username = jwtTokenUtil.getUserNameFromToken(authToken);
            LOGGER.info("checking username:{}", username);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    LOGGER.debug(authentication.toString());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    LOGGER.info("authenticated user:{}", username);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}
public class JwtTokenUtil {
    /**
     * 从token中获取登录用户名
     */
    public String getUserNameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 从token中获取JWT中的负载
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims = null;
        try {
            claims = Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            LOGGER.info("JWT格式验证失败:{}", token);
        }
        return claims;
    }
}

可以看到上面有下面4句话

String username = jwtTokenUtil.getUserNameFromToken(authToken);
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);

是不是和 SecurityContextHolderAwareRequestWrapper 中的 Authentication auth = getAuthentication();、((UserDetails) auth.getPrincipal()).getUsername(); 有什么关联性?

我尝试加了些日志,把多个 authentication 内存地址都打印出来

2020-11-18 17:33:13.116 DEBUG 15712 --- [nio-8080-exec-9] c.m.m.s.c.JwtAuthenticationTokenFilter   : org.springframework.security.authentication.UsernamePasswordAuthenticationToken@b371c6f3: Principal: com.macro.mall.bo.AdminUserDetails@cb89c97; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: 1:商品品牌管理, ...
2020-11-18 17:33:13.117 DEBUG 15712 --- [nio-8080-exec-9] c.m.m.s.component.DynamicSecurityFilter  : Previously Authenticated: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@b371538d: Principal: com.macro.mall.bo.AdminUserDetails@cb89c97; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: 1:商品品牌管理, ...
2020-11-18 17:33:13.206 DEBUG 15712 --- [nio-8080-exec-9] com.macro.mall.common.log.WebLogAspect   : org.springframework.security.authentication.UsernamePasswordAuthenticationToken@b371538d: Principal: com.macro.mall.bo.AdminUserDetails@cb89c97; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: 1:商品品牌管理...

可以发现,DynamicSecurityFilter 与 WebLogAspect 的 UsernamePasswordAuthenticationToken 内存地址都是 b371538d,JwtAuthenticationTokenFilter 的 UsernamePasswordAuthenticationToken 内存地址是 b371c6f3,三者的 AdminUserDetails 内存地址都是 b371538d

因此我可以猜测一个结论:webLog.setIp(request.getRemoteUser()); 中的 remoteUser,是通过 JwtAuthenticationTokenFilter 或者 DynamicSecurityFilter 中设置的,具体方法大概是 将 username 传入 UserDetails,再将 UserDetails 传入 UsernamePasswordAuthenticationToken,而在 request.getRemoteUser() 中,可通过 Authentication auth = getAuthentication();、((UserDetails) auth.getPrincipal()).getUsername(); 的方式获取到 username

peteryuanpan commented 4 years ago

一站式登陆功能分析(二)

现在来看下前端

user.js

import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'

const user = {
  state: {
    token: getToken(),
    name: '',
    avatar: '',
    roles: []
  },

  mutations: {
    SET_TOKEN: (state, token) => {
      state.token = token
    },
    SET_NAME: (state, name) => {
      state.name = name
    },
    SET_AVATAR: (state, avatar) => {
      state.avatar = avatar
    },
    SET_ROLES: (state, roles) => {
      state.roles = roles
    }
  },

  actions: {
    // 登录
    Login({ commit }, userInfo) {
      const username = userInfo.username.trim()
      return new Promise((resolve, reject) => {
        login(username, userInfo.password).then(response => {
          const data = response.data
          const tokenStr = data.tokenHead+data.token
          setToken(tokenStr)
          commit('SET_TOKEN', tokenStr)
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 获取用户信息
    GetInfo({ commit, state }) {
      return new Promise((resolve, reject) => {
        getInfo().then(response => {
          const data = response.data
          if (data.roles && data.roles.length > 0) { // 验证返回的roles是否是一个非空数组
            commit('SET_ROLES', data.roles)
          } else {
            reject('getInfo: roles must be a non-null array !')
          }
          commit('SET_NAME', data.username)
          commit('SET_AVATAR', data.icon)
          resolve(response)
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 登出
    LogOut({ commit, state }) {
      return new Promise((resolve, reject) => {
        logout(state.token).then(() => {
          commit('SET_TOKEN', '')
          commit('SET_ROLES', [])
          removeToken()
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 前端 登出
    FedLogOut({ commit }) {
      return new Promise(resolve => {
        commit('SET_TOKEN', '')
        removeToken()
        resolve()
      })
    }
  }
}

export default user

login.js

import request from '@/utils/request'

export function login(username, password) {
  return request({
    url: '/admin/login',
    method: 'post',
    data: {
      username,
      password
    }
  })
}

auth.js

import Cookies from 'js-cookie'

const TokenKey = 'loginToken'

export function getToken() {
  return Cookies.get(TokenKey)
}

export function setToken(token) {
  return Cookies.set(TokenKey, token)
}

export function removeToken() {
  return Cookies.remove(TokenKey)
}

menu.js

import request from '@/utils/request'

export function fetchList(parentId, params) {
  return request({
    url: '/menu/list/' + parentId,
    method: 'get',
    params: params
  })
}

export function deleteMenu(id) {
  return request({
    url: '/menu/delete/' + id,
    method: 'post'
  })
}

user.js 负责登陆、登出等功能,它会提交到 login.js 中的 login 函数,注意到 user.js 的 Login 中有一个 setToken,LogOut 中有一个 removeToken,分别对应 auth.js 的代码,都调用了 Cookies

还注意到 menu.js 这样的业务请求(非login请求)中,根本没有传 token 参数

因此猜测:在 user.js 的 Login 中 setToken 后,将 token 存于 Cookies 中

抓了下前端的请求参数,如下,其中有 Authorization

Request

Request URL: http://localhost:8080/admin/info
Request Method: GET
Status Code: 200 
Remote Address: [::1]:8080
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImNyZWF0ZWQiOjE2MDU3MDA5MTY5MzcsImV4cCI6MTYwNjMwNTcxNn0.W88TxAlTINrjuEt2Ofp43FKR4X8N_xAQ7FjcOOrXVCCtvlfQ9hnIR_0BaxYjNxOAi-vZAdn5GQUnh7W3tpxnig

打开chrome查看也能对应上,如下,把 Application.Cookies clear掉,再刷新界面,会回到 loginPage,登录后,Application.Cookies 中又有值了,也是如下

image

思考一下:前端在 login 接口返回成功后,是如何跳转到主页面的?

结论是:前端进行的跳转(而非后端302跳转)

下面是 /login 接口,可以看出返回的状态码是200,而不是302,而 https://github.com/peteryuanpan/notebook/issues/143#issuecomment-730122206 中我是通过 302跳转来实现的,逻辑不一样

UmsAdminController

@Controller
@Api(tags = "UmsAdminController", description = "后台用户管理")
@RequestMapping("/admin")
public class UmsAdminController {
    @ApiOperation(value = "登录以后返回token")
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public CommonResult login(@Validated @RequestBody UmsAdminLoginParam umsAdminLoginParam) {
        String token = adminService.login(umsAdminLoginParam.getUsername(), umsAdminLoginParam.getPassword());
        if (token == null) {
            return CommonResult.validateFailed("用户名或密码错误");
        }
        Map<String, String> tokenMap = new HashMap<>();
        tokenMap.put("token", token);
        tokenMap.put("tokenHead", tokenHead);
        return CommonResult.success(tokenMap);
    }
}

CommonResult

public class CommonResult<T> {
    /**
     * 状态码
     */
    private long code;
    /**
     * 提示信息
     */
    private String message;
    public static <T> CommonResult<T> success(T data) {
        return new CommonResult<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
    }
}

ResultCode

public enum ResultCode implements IErrorCode {
    SUCCESS(200, "操作成功"),
    FAILED(500, "操作失败"),
    VALIDATE_FAILED(404, "参数检验失败"),
    UNAUTHORIZED(401, "暂未登录或token已经过期"),
    FORBIDDEN(403, "没有相关权限");
    private long code;
    private String message;
}

再尝试找一下前端的代码,看到 handleLogin 中有一句话 this.$router.push({path: '/'}),猜测是在登陆成功后,默认跳转到 /,也就是主页面

index.vue

        <el-form-item prop="password">
          <el-input name="password"
                    :type="pwdType"
                    @keyup.enter.native="handleLogin"
                    v-model="loginForm.password"
                    autoComplete="on"
                    placeholder="请输入密码">
          <span slot="prefix">
            <svg-icon icon-class="password" class="color-main"></svg-icon>
          </span>
            <span slot="suffix" @click="showPwd">
            <svg-icon icon-class="eye" class="color-main"></svg-icon>
          </span>
          </el-input>
        </el-form-item>
        <el-form-item style="margin-bottom: 60px;text-align: center">
          <el-button style="width: 45%" type="primary" :loading="loading" @click.native.prevent="handleLogin">
            登录
          </el-button>
          <el-button style="width: 45%" type="primary" @click.native.prevent="handleTry">
            获取体验账号
          </el-button>
        </el-form-item>
...
      handleLogin() {
        this.$refs.loginForm.validate(valid => {
          if (valid) {
            // let isSupport = getSupport();
            // if(isSupport===undefined||isSupport==null){
            //   this.dialogVisible =true;
            //   return;
            // }
            this.loading = true;
            this.$store.dispatch('Login', this.loginForm).then(() => {
              this.loading = false;
              setCookie("username",this.loginForm.username,15);
              setCookie("password",this.loginForm.password,15);
              this.$router.push({path: '/'})
            }).catch(() => {
              this.loading = false
            })
          } else {
            console.log('参数验证不合法!');
            return false
          }
        })
      },

同时找到下面这一段代码,也体现了类似的逻辑,先通过 getToken() 获取 token,也就是 Application.Cookies 中 loginToken, 若获取不到,根据白名单进行判断,若获取得到,path 是 /login 的话 则下一步跳转到 /,否则根据 用户的权限生成可访问的路由表并动态添加

permission.js

import router from './router'
import store from './store'
import NProgress from 'nprogress' // Progress 进度条
import 'nprogress/nprogress.css'// Progress 进度条样式
import { Message } from 'element-ui'
import { getToken } from '@/utils/auth' // 验权

const whiteList = ['/login'] // 不重定向白名单
router.beforeEach((to, from, next) => {
  NProgress.start()
  if (getToken()) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done() // if current page is dashboard will not trigger afterEach hook, so manually handle it
    } else {
      if (store.getters.roles.length === 0) {
        store.dispatch('GetInfo').then(res => { // 拉取用户信息
          let menus=res.data.menus;
          let username=res.data.username;
          store.dispatch('GenerateRoutes', { menus,username }).then(() => { // 生成可访问的路由表
            router.addRoutes(store.getters.addRouters); // 动态添加可访问路由表
            next({ ...to, replace: true })
          })
        }).catch((err) => {
          store.dispatch('FedLogOut').then(() => {
            Message.error(err || 'Verification failed, please login again')
            next({ path: '/' })
          })
        })
      } else {
        next()
      }
    }
  } else {
    if (whiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      next('/login')
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done() // 结束Progress
})

同样的,打开chrome查看也能对应上,未登陆或退出登陆时,Application.Cookies 中会没有 loginToken,登陆后会出现 loginToken

image

image

peteryuanpan commented 3 years ago

来看下Controller + Mybatis + Mysql

getAdminInfo,对应 /admin/info 接口,这里就大有文章

@Controller
@Api(tags = "UmsAdminController", description = "后台用户管理")
@RequestMapping("/admin")
public class UmsAdminController {

    @ApiOperation(value = "获取当前登录用户信息")
    @RequestMapping(value = "/info", method = RequestMethod.GET)
    @ResponseBody
    public CommonResult getAdminInfo(Principal principal) {
        if (principal == null) {
            return CommonResult.unauthorized(null);
        }
        String username = principal.getName();
        UmsAdmin umsAdmin = adminService.getAdminByUsername(username);
        Map<String, Object> data = new HashMap<>();
        data.put("username", umsAdmin.getUsername());
        data.put("menus", roleService.getMenuList(umsAdmin.getId()));
        data.put("icon", umsAdmin.getIcon());
        List<UmsRole> roleList = adminService.getRoleList(umsAdmin.getId());
        if (CollUtil.isNotEmpty(roleList)) {
            List<String> roles = roleList.stream().map(UmsRole::getName).collect(Collectors.toList());
            data.put("roles", roles);
        }
        return CommonResult.success(data);
    }
}

UmsAdmin umsAdmin = adminService.getAdminByUsername(username); 这句话对应着 UmsAdminServiceImpl

public class UmsAdminServiceImpl implements UmsAdminService {

    @Override
    public UmsAdmin getAdminByUsername(String username) {
        UmsAdmin admin = adminCacheService.getAdmin(username);
        if (admin != null)
            return  admin;
        UmsAdminExample example = new UmsAdminExample();
        example.createCriteria().andUsernameEqualTo(username);
        List<UmsAdmin> adminList = adminMapper.selectByExample(example);
        if (adminList != null && adminList.size() > 0) {
            admin = adminList.get(0);
            adminCacheService.setAdmin(admin);
            return admin;
        }
        return null;
    }
}

List adminList = adminMapper.selectByExample(example); 这句话负责从数据库中获取数据

UmsAdminMapper是一个Mybatis的Mapper类

public interface UmsAdminMapper {
    long countByExample(UmsAdminExample example);

    int deleteByExample(UmsAdminExample example);

    int deleteByPrimaryKey(Long id);

    int insert(UmsAdmin record);

    int insertSelective(UmsAdmin record);

    List<UmsAdmin> selectByExample(UmsAdminExample example);

    UmsAdmin selectByPrimaryKey(Long id);

    int updateByExampleSelective(@Param("record") UmsAdmin record, @Param("example") UmsAdminExample example);

    int updateByExample(@Param("record") UmsAdmin record, @Param("example") UmsAdminExample example);

    int updateByPrimaryKeySelective(UmsAdmin record);

    int updateByPrimaryKey(UmsAdmin record);
}

以 selectByExample 举例,对应的部分xml如下

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.macro.mall.mapper.UmsAdminMapper">
  <resultMap id="BaseResultMap" type="com.macro.mall.model.UmsAdmin">
    <id column="id" jdbcType="BIGINT" property="id" />
    <result column="username" jdbcType="VARCHAR" property="username" />
    <result column="password" jdbcType="VARCHAR" property="password" />
    <result column="icon" jdbcType="VARCHAR" property="icon" />
    <result column="email" jdbcType="VARCHAR" property="email" />
    <result column="nick_name" jdbcType="VARCHAR" property="nickName" />
    <result column="note" jdbcType="VARCHAR" property="note" />
    <result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
    <result column="login_time" jdbcType="TIMESTAMP" property="loginTime" />
    <result column="status" jdbcType="INTEGER" property="status" />
  </resultMap>

  <select id="selectByExample" parameterType="com.macro.mall.model.UmsAdminExample" resultMap="BaseResultMap">
    select
    <if test="distinct">
      distinct
    </if>
    <include refid="Base_Column_List" />
    from ums_admin
    <if test="_parameter != null">
      <include refid="Example_Where_Clause" />
    </if>
    <if test="orderByClause != null">
      order by ${orderByClause}
    </if>
  </select>

</mapper>

这是在从数据库中sql数据,用MySQLWorkbench,SELECT一下,数据结果如下

image

Mybatis帮忙从数据库中获取用户数据信息,并且生成一个 List 实例,在JAVA中使用

在application-dev.yml如下

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    druid:
      initial-size: 5 #连接池初始化大小
      min-idle: 10 #最小空闲连接数
      max-active: 20 #最大连接数
      web-stat-filter:
        exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" #不统计这些请求数据
      stat-view-servlet: #访问监控网页的登录用户名和密码
        login-username: druid
        login-password: druid
peteryuanpan commented 3 years ago

按照 http://www.macrozheng.com/#/deploy/mall_swarm_deploy_windows 部署 mall-swarm 项目

Nacos image

SpringBootAdmin image

Mall后台系统 image

mall-swarm 与 mall 项目不一样,它是基于 SpringCloud 的,所有请求先需要到 mall-gateway 中过滤,因此所有请求都以 http://localhost:8201/x/y 开头,请求端口固定是8201(可以通过配置文件修改),x 是 mall-auth、mall-admin、mall-portal 等子模块

mall-gateway 的 application,yml

server:
  port: 8201
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true #使用小写service-id
      routes: #配置路由路径
        - id: mall-auth
          uri: lb://mall-auth
          predicates:
            - Path=/mall-auth/**
          filters:
            - StripPrefix=1
        - id: mall-admin
          uri: lb://mall-admin
          predicates:
            - Path=/mall-admin/**
          filters:
            - StripPrefix=1
        - id: mall-portal
          uri: lb://mall-portal
          predicates:
            - Path=/mall-portal/**
          filters:
            - StripPrefix=1
        - id: mall-search
          uri: lb://mall-search
          predicates:
            - Path=/mall-search/**
          filters:
            - StripPrefix=1
        - id: mall-demo
          uri: lb://mall-demo
          predicates:
            - Path=/mall-demo/**
          filters:
            - StripPrefix=1
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://localhost:8201/mall-auth/rsa/publicKey' #配置RSA的公钥访问地址
  redis:
    database: 0
    port: 6379
    host: localhost
    password:
secure:
  ignore:
    urls: #配置白名单路径
      - "/doc.html"
      - "/swagger-resources/**"
      - "/swagger/**"
      - "/**/v2/api-docs"
      - "/**/*.js"
      - "/**/*.css"
      - "/**/*.png"
      - "/**/*.ico"
      - "/webjars/springfox-swagger-ui/**"
      - "/actuator/**"
      - "/mall-auth/oauth/token"
      - "/mall-auth/rsa/publicKey"
      - "/mall-search/**"
      - "/mall-portal/sso/login"
      - "/mall-portal/sso/register"
      - "/mall-portal/sso/getAuthCode"
      - "/mall-portal/home/**"
      - "/mall-portal/product/**"
      - "/mall-portal/brand/**"
      - "/mall-admin/admin/login"
      - "/mall-admin/admin/register"
      - "/mall-admin/minio/upload"
management: #开启SpringBoot Admin的监控
  endpoints:
    web:
      exposure:
        include: '*'
  endpoint:
    health:
      show-details: always

mall-gateway 中 ResourceServerConfig 有 OAuth 的配置

package com.macro.mall.config;

import cn.hutool.core.util.ArrayUtil;
import com.macro.mall.authorization.AuthorizationManager;
import com.macro.mall.common.constant.AuthConstant;
import com.macro.mall.component.RestAuthenticationEntryPoint;
import com.macro.mall.component.RestfulAccessDeniedHandler;
import com.macro.mall.filter.IgnoreUrlsRemoveJwtFilter;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;

/**
 * 资源服务器配置
 * Created by macro on 2020/6/19.
 */
@AllArgsConstructor
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
    private final AuthorizationManager authorizationManager;
    private final IgnoreUrlsConfig ignoreUrlsConfig;
    private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
    private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    private final IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        //自定义处理JWT请求头过期或签名错误的结果
        http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
        //对白名单路径,直接移除JWT请求头
        http.addFilterBefore(ignoreUrlsRemoveJwtFilter,SecurityWebFiltersOrder.AUTHENTICATION);
        http.authorizeExchange()
                .pathMatchers(ArrayUtil.toArray(ignoreUrlsConfig.getUrls(),String.class)).permitAll()//白名单配置
                .anyExchange().access(authorizationManager)//鉴权管理器配置
                .and().exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler)//处理未授权
                .authenticationEntryPoint(restAuthenticationEntryPoint)//处理未认证
                .and().csrf().disable();
        return http.build();
    }

    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }

}