欢迎光临
我们一直在努力

后台认证授权、小程序登录

1.RBAC权限模型

1.1概述

在企业系统中,通过配置用户的功能权限让不同的人分管不同业务。基于RBAC(Role Based Access Control)模型,主要是将功能组合成角色,再将角色分配给用户,也就是说角色是功能的合集。

比如:

企业A总共有12个功能,需要创建100个用户。功能包括财务管理、人事管理、销售管理等等,如果不引入基于角色的访问控制(RBAC)模型,我们就需要每创建一个用户就要分配一次功能,这将至少需要进行100次操作(每个用户只能拥有一个功能的前提下)。如果用户数量增加到1000甚至10000,并且一个用户可能会拥有多个功能,操作将会变得非常繁琐。如图:

经过多次操作后发现,有些人被分配了相同的功能。例如,A、B等10个用户都被分配了客户管理、订单管理和供应商管理这几个模块。我们可以将这几个功能模块组合成一个包,然后将整个包分配给需要的用户。这个包被称为角色。由于角色和功能之间的对应关系相对稳定,在给用户分配权限时只需分配角色即可,如下图所示:

基于RBAC授权模式后,我们可以达成以下2个目标:

  • 解耦用户和功能,降低操作错误率

  • 降低功能权限分配的繁琐程度

1.2ER图与关系梳理

在一个核心业务系统中,我们通常通过业务分析,从而抽离出数据库表,表确定之后我们会进一步分析表中应该有的字段,先看一份业务ER图:

上图中清楚的描述用户、角色、资源、职位、部门之间的关系,同时我们进一步推导出以下结果:

  • 用户与职位是N:N关系

  • 用户与部门是N:1关系

  • 用户与角色是N:N关系

  • 角色与资源是N:N关系

  • 部门与角色是N:N关系

表关系(至少包含右侧五张表):

2.Spring Security

2.1概述

Spring Security是一个基于Spring框架的安全性解决方案,它为应用程序提供了完整的安全管理功能,包括认证、授权、攻击防范和会话管理等,官网地址:https://spring.io/projects/spring-security。

在若依系统中,已经集成了Spring Security安全框架。

2.2核心作用

  • 用户登录

    • 校验验证码是否正确

    • 通过spring security框架验证用户是否存在,密码是否正确

    • 查询用户所拥有的权限列表

    • 最后把用户信息封装到jwt生成的token中返回

  • 其他请求(可以是非登录的任意请求),这些校验工作可以全部交给Spring Security来完成

    • url是否需要检验(访问接口是否需要放行,比如登录、注册、验证码这些接口;还有一些静态资源也需要放行,比如html、js、css等)

    • token校验,是否为空,是否过期,是否正常解析

    • 校验当前访问的url与当前用户的权限是否匹配,匹配则放行,不匹配则是权限不足

2.3权限控制

2.3.1认证

认证(Authentication)

认证是用户登录系统时,通过比较用户提供的凭证(如用户名和密码)与系统中存储的信息是否一致,以验证用户身份真实有效的过程。

当前系统的认证流程:

2.3.2授权

授权(Authorization):

  • 授权就是用户登录后(认证后),控制用户是否有权限访问某些资源。

  • 下图是给用户赋予角色,并且可以可以赋予多个角色

下图中,可以给角色赋予多个功能权限

下图中,我们可以对菜单和按钮进行权限标识的配置

2.3.3权限验证

一个请求过来之后,需要有诸多的校验来能判断是否能有效访问

  • 接收到请求,比如是doc.html或login,是一个静态资源,可直接放行,在SecurityConfig配置放行

  • 接收到请求,比如是/nursing/elder/save,在SecurityConfig没有配置放行,需要校验当前请求携带的token是否有效,如果token无效,则是认证失败,需要重新认证(跳转到登录页)

  • 接收到请求,比如是/nursing/elder/save,在SecurityConfig没有配置放行,token也有效,则判断当前登录人是否拥有该权限(认证成功后,可以获取用户的权限列表)

    • 如果有权限,则放行

    • 如果没权限,则提醒权限不足

/**
* spring security配置
*
* @author ruoyi
*/
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class SecurityConfig
{
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;

/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;

/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;

/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;

/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;

/**
* 允许匿名访问的地址
*/
@Autowired
private PermitAllUrlProperties permitAllUrl;

/**
* 身份验证实现
*/
@Bean
public AuthenticationManager authenticationManager()
{
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
return new ProviderManager(daoAuthenticationProvider);
}

/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Bean
protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception
{
return httpSecurity
// CSRF禁用,因为不使用session
.csrf(csrf -> csrf.disable())
// 禁用HTTP响应标头
.headers((headersCustomizer) -> {
headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
})
// 认证失败处理类
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
// 基于token,所以不需要session
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 注解标记允许匿名访问的url
.authorizeHttpRequests((requests) -> {
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
requests.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
})
// 添加Logout filter
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
// 添加JWT filter
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
// 添加CORS filter
.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class)
.addFilterBefore(corsFilter, LogoutFilter.class)
.build();
}

/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
}

具体的url检验是通过Spring Security的提供的权限注解@PreAuthorize实现的,可以判断具体的url是否可以访问

  • @PreAuthorize 是 Spring Security 框架中提供的一个安全注解,用于实现基于注解的访问控制。它允许开发者在方法级别上声明特定的安全约束,以确保只有满足指定条件的用户才能调用该方法

  • 当 @PreAuthorize 注解被应用于某个方法时,Spring Security 在该方法执行前会先对当前认证的用户进行权限检查。如果检查通过,方法调用得以继续;否则,框架会抛出相应的权限异常(如 AccessDeniedException),阻止方法执行。

  • 权限控制代码,若依框架生成的Controller中都可以见到,如下:

@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {

}

  • @PreAuthorize Spring Security框架的权限注解,在执行方法前执行

    • 已经开启注解生效,在SecurityConfig类中添加了如下注解:

    • @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)

  • @ss.hasPermi('system:user:list')

    • 其中的ss是一个被spring管理的bean

      • 位置:zzyl-framework模块中的com.zzyl.framework.web.service.PermissionService

    • hasPermi 是PermissionService类中的一个方法,判断是否拥有该权限

    • system:user:list 为方法的参数

  • 权限控制的流程

3.第三方接口

3.1接口文档

在项目中对接任何第三方接口,最重要的参考永远都是官方给提供的接口文档。

接口文档该怎么阅读呢必然会包含接口的四要素(请求路径、请求方式、入参、出参)

这个是用于微信登录的接口,链接如下:

https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html

这个文档中关键的部分是:

  • 功能描述,查阅是否符合我们业务对接的需求

  • 调用方式,三方接口一般都是https居多,更加安全,包含了请求方式和请求地址

  • 请求参数,接口的入参,重点关注必须要传的参数

  • 返回参数,查看返回的数据结构和内容,是否符合自己的返回预期

  • 错误码,当接口调用失败的时候,可以根据错误码对应的描述修正代码解决问题

3.2API客户端

如果我们阅读明白了接口文档,也符合我们的业务需求,那么就可以在我们的后台服务中调用三方接口。

不同的接口调用的方式不一样,但基本的思路都是基于http请求来调用,不过,我们之前使用的http请求,都是前端到后端来发起请求,如下图

  • Client客户端一般都是指浏览器发起了请求

  • Server是指服务端,Java的后端服务

从后端调用三方接口,也是用的http请求,只是这次请求是从后端到另外一个后端服务,我们需要借助工具才能发起请求

HTTP客户端工具有很多

  • HttpClient 体量庞大,jdk11以上支持不好

  • OkHttp 学习成本较高

  • Spring提供的 RestTemplate

  • 糊涂工具包,使用说明地址:https://doc.hutool.cn/pages/http/

3.3Hutool工具常见方法

Hutool工具的官方文档:https://doc.hutool.cn/pages/HttpUtil/

  • GET请求

package com.zzyl.nursing.test;

import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.http.Method;
import org.junit.Test;

import java.util.HashMap;
import java.util.Map;

public class HttpTest {

@Test
public void testGet() {
String result = HttpUtil.get("https://www.baidu.com");
System.out.println(result);
}

@Test
public void testGetByParam() {
// 构建查询参数
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("pageNum", 1);
paramMap.put("pageSize", 5);
// 分页查询护理项目
String result = HttpUtil.get("http://localhost:8080/nursing/project/list", paramMap);
System.out.println(result);
}

@Test
public void testCreateRequest() {
// 构建查询参数
Map<String,Object> paramMap = new HashMap<>();
paramMap.put("pageNum", 1);
paramMap.put("pageSize", 5);
// 分页查询护理项目
HttpResponse response = HttpUtil.createRequest(Method.GET, "http://localhost:8080/nursing/project/list")
.header("Authorization", "Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImUyNTgyNzdhLTMxNzAtNGFhNS05MmNhLWRiNjFiNDYyOGYxNCJ9.p8ieeQe4A6lozsxpzTL4Z3JNAS3UgBOVRZckDFnNvJQs8C2I5X5nZVxAQCaUv_0u9yUN-d0FKDCHwtCkMeIdBA")
.form(paramMap)
.execute();
if(response.isOk()){
System.out.println(response.body());
}
}
}

  • POST请求

@Test
public void testPost() {
String url = "http://localhost:8080/nursing/project";
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("name", "护理项目测试");
paramMap.put("orderNo", 1);
paramMap.put("unit", "次");
paramMap.put("price", 10.00);
paramMap.put("image", "https://yjy-slwl-oss.oss-cn-hangzhou.aliyuncs.com/ae7cf766-fb7b-49ff-a73c-c86c25f280e1.png");
paramMap.put("nursingRequirement", "无特殊要求");
paramMap.put("status", 1);
String result = HttpUtil.post(url, JSONUtil.toJsonStr(paramMap));
System.out.println(result);
}

@Test
public void testCreatePost() {
String url = "http://localhost:8080/nursing/project";
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("name", "护理项目测试");
paramMap.put("orderNo", 1);
paramMap.put("unit", "次");
paramMap.put("price", 10.00);
paramMap.put("image", "https://yjy-slwl-oss.oss-cn-hangzhou.aliyuncs.com/ae7cf766-fb7b-49ff-a73c-c86c25f280e1.png");
paramMap.put("nursingRequirement", "无特殊要求");
paramMap.put("status", 1);

// 发送请求
HttpResponse response = HttpUtil.createPost(url).body(JSONUtil.toJsonStr(paramMap))
.header("Authorization", "Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImUyNTgyNzdhLTMxNzAtNGFhNS05MmNhLWRiNjFiNDYyOGYxNCJ9.p8ieeQe4A6lozsxpzTL4Z3JNAS3UgBOVRZckDFnNvJQs8C2I5X5nZVxAQCaUv_0u9yUN-d0FKDCHwtCkMeIdBA")
.execute();
if(response.isOk()) {
System.out.println(response.body());
}

}

4.微信小程序登录

4.1登录思路

微信小程序登录,官方文档:

小程序登录 | 微信开放文档

微信官方推荐登录流程:

注意:

  • 前端在小程序中集成微信相关依赖,当用户请求登录的同时,调用wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。

  • 后端服务器调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台账号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台账号) 和 会话密钥 session_key

  • 开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。

4.2表结构说明

从小程序端登录的用户主要有两类,第一类是参观预约的用户,第二类是老人的家人(子女),方便查看老人信息、给老人下单、查看账单、查看合同等服务。

如果是老人的家人,需要跟入住的老人进行绑定,方便后期享受更多服务,所以,我们需要将在小程序端登录的用户保存下来,保存到一张表中 family_member(家属表)

4.3接口说明

参考微信开发者平台提供的流程进行开发。

该部分在后台提供接口即可

  • 接口路径(已固定):/member/user/login

  • 请求方式(已固定):POST

  • 请求参数:(已固定)

{
"code": "0e36jkGa1ercRF0Fu4Ia1V3fPD06jkGW", // 临时登录凭证code
"nickName": "微信用户",
"phoneCode": "13fe315872a4fb9ed3deee1e5909d5af60dfce7911013436fddcfe13f55ecad3"
}

  以上三个参数,都是前端开发人员调用wx.login()方法返回的数据

  • code:临时登录凭证code

  • nickName:微信用户昵称(现在微信统一返回为:微信用户)

  • phoneCode:详细用户信息code,后台根据此code可以获取用户手机号

响应示例

{
"code": 200,
"msg": "操作成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLlpb3mn7_lvIDoirE4OTE1IiwiZXhwIjoxNDY1MjI3MTMyOCwidXNlcmlkIjoxfQ.nB6ElZbUywh-yiHDNMJS8WqUpcLWCszVdvAMfySFxIM",
"nickName": "好柿开花8915"
},
"operationTime": null
}

4.4功能实现

基于微信官方提供的流程图,结合业务,最终的实现思路,如下图

4.4.1控制层

新定义一个登录方法,代码如下:

package com.zzyl.nursing.controller.member;

import com.zzyl.common.core.controller.BaseController;
import com.zzyl.common.core.domain.AjaxResult;
import com.zzyl.nursing.dto.UserLoginRequestDto;
import com.zzyl.nursing.service.IFamilyMemberService;
import com.zzyl.nursing.vo.LoginVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 老人家属Controller
*
* @author ruoyi
* @date 2024-09-02
*/
@RestController
@RequestMapping("/member/user")
@Api(tags = "老人家属相关接口")
public class FamilyMemberController extends BaseController
{
@Autowired
private IFamilyMemberService familyMemberService;

@PostMapping("/login")
@ApiOperation("小程序登录")
public AjaxResult login(@RequestBody UserLoginRequestDto userLoginRequestDto) {
LoginVo loginVo = familyMemberService.login(userLoginRequestDto);
return success(loginVo);
}

}

基于接口文档创建对应的DTO和VO

UserLoginRequestDto

package com.zzyl.nursing.dto;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
* C端用户登录
*/
@Data
public class UserLoginRequestDto {

@ApiModelProperty("昵称")
private String nickName;

@ApiModelProperty("登录临时凭证")
private String code;

@ApiModelProperty("手机号临时凭证")
private String phoneCode;
}

LoginVo

package com.zzyl.nursing.vo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
* LoginVO
* @author itheima
*/
@Data
@ApiModel(value = "登录对象")
public class LoginVo {

@ApiModelProperty(value = "JWT token")
private String token;

@ApiModelProperty(value = "昵称")
private String nickName;
}

4.4.2业务层

在后台实现的过程中,我们需要调用两次微信开发者平台的接口来获取数据,一般像这种第三方接口的调用,我们通常都会将所有相关接口的调用都封装到一个单独的业务类中(Service层接口和实现类),使其更通用

我们可以先分析微信开发者平台的接口,接口地址:

  • 获取用户openId

  • 获取手机号

  • 获取token

4.4.2.1微信接口调用-单独封装

新增WechatService接口

package com.zzyl.nursing.service;

public interface WechatService {

/**
* 获取openid
* @param code
* @return
*/
public String getOpenid(String code);

/**
* 获取手机号
* @param detailCode
* @return
*/
public String getPhone(String detailCode);
}

新增WechatServiceImpl实现类

package com.zzyl.nursing.service.impl;

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.zzyl.nursing.service.WechatService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class WechatServiceImpl implements WechatService {

// 登录
private static final String REQUEST_URL = "https://api.weixin.qq.com/sns/jscode2session?grant_type=authorization_code";

// 获取token
private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential";

// 获取手机号
private static final String PHONE_REQUEST_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=";

@Value("${wechat.appId}")
private String appid;

@Value("${wechat.appSecret}")
private String secret;

/**
* 获取openid
* @param code
* @return
*/
@Override
public String getOpenid(String code) {

// 获取公共参数
Map<String,Object> paramMap = getAppConfig();
paramMap.put("js_code", code);

String result = HttpUtil.get(REQUEST_URL, paramMap);
// 结果是一个map
JSONObject jsonObject = JSONUtil.parseObj(result);
// 判断接口响应是否出错
if(ObjectUtil.isNotEmpty(jsonObject.getInt("errcode"))) {
throw new RuntimeException(jsonObject.getStr("errmsg"));
}

String openid = jsonObject.getStr("openid");

return openid;
}

/**
* 封装公共参数
* @return
*/
private Map<String, Object> getAppConfig() {
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("appid", appid);
paramMap.put("secret", secret);
return paramMap;
}

/**
* 获取手机号
* @param detailCode
* @return
*/
@Override
public String getPhone(String detailCode) {

String token = getToken();
String url = PHONE_REQUEST_URL + token;
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("code", detailCode);
// 发起请求
String result = HttpUtil.post(url, JSONUtil.toJsonStr(paramMap));
// 是一个map
JSONObject jsonObject = JSONUtil.parseObj(result);
// 判断接口响应是否出错
if(jsonObject.getInt("errcode") != 0) {
throw new RuntimeException(jsonObject.getStr("errmsg"));
}

return jsonObject.getJSONObject("phone_info").getStr("phoneNumber");
}

/**
* 获取token
* @return
*/
private String getToken() {

Map<String, Object> paramMap = getAppConfig();
// 发起请求
String result = HttpUtil.get(TOKEN_URL, paramMap);
// 是一个map
JSONObject jsonObject = JSONUtil.parseObj(result);
// 判断接口响应是否出错
if(ObjectUtil.isNotEmpty(jsonObject.getInt("errcode"))) {
throw new RuntimeException(jsonObject.getStr("errmsg"));
}

String token = jsonObject.getStr("access_token");

return token;

}
}

上述代码需要读取配置来获取微信小程序的appid和appSecret ,所以需要在application.yml添加对应配置,要和在微信开发者平台设置的appid和appSecret相同

单元测试:

在zzyl-admin中创建单元测试WechatServiceImplTest

package com.zzyl.test;

import com.zzyl.nursing.service.WechatService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class WechatServiceImplTest {

@Autowired
private WechatService wechatService;

@Test
public void testGetOpenid() {
String openid = wechatService.getOpenid("0d3fPOkl2VLGbe4IACol2M0x5Q2fPOk5");
System.out.println(openid);//o3CsK6_C6f4WP9b0AxXNJOkc6q9Q
}

@Test
public void testGetPhone() {
String phone = wechatService.getPhone("ff1a2f2e61482537ea19c8e57850919051b43ffadbf78cd4fcd3a1707e0c1e14");
System.out.println(phone);
}
}

参数获取方式:在小程序端获取参数,输入到单元测试中

4.4.2.2微信登录业务开发

在IFamilyMemberService中定义login方法

/**
* 微信登录
* @param userLoginRequestDto
* @return
*/
LoginVo login(UserLoginRequestDto userLoginRequestDto);

实现方法:

@Autowired
private WechatService wechatService;

@Autowired
private TokenService tokenService;

static List<String> DEFAULT_NICKNAME_PREFIX = ListUtil.of(
"生活更美好",
"大桔大利",
"日富一日",
"好柿开花",
"柿柿如意",
"一椰暴富",
"大柚所为",
"杨梅吐气",
"天生荔枝"
);

/**
* 小程序端登录
* @param userLoginRequestDto
* @return
*/
@Override
public LoginVo login(UserLoginRequestDto userLoginRequestDto) {
// 1.调用微信api,根据code获取openId
String openId = wechatService.getOpenid(userLoginRequestDto.getCode());

// 2.根据openId查询用户
FamilyMember familyMember = getOne(Wrappers.<FamilyMember>lambdaQuery(FamilyMember.class)
.eq(FamilyMember::getOpenId, openId));

// 3.如果用户为空,则将openId设置到对象中
if (ObjectUtil.isEmpty(familyMember)) {
familyMember = FamilyMember.builder().openId(openId).build();
}

// 4.调用微信api获取用户绑定的手机号
String phone = wechatService.getPhone(userLoginRequestDto.getPhoneCode());

// 5.保存或修改用户
saveOrUpdateFamilyMember(familyMember, phone);

// 6.将用户id存入token,返回
Map<String, Object> claims = new HashMap<>();
claims.put("userId", familyMember.getId());
claims.put("nickName", familyMember.getName());

String token = tokenService.createToken(claims);
LoginVo loginVo = new LoginVo();
loginVo.setToken(token);
loginVo.setNickName(familyMember.getName());
return loginVo;
}

/**
* 保存或修改客户
* @param member
* @param phone
*/
private void saveOrUpdateFamilyMember(FamilyMember member, String phone) {

// 1.判断取到的手机号与数据库中保存的手机号不一样
if(ObjectUtil.notEqual(phone, member.getPhone())) {
// 修改手机号
member.setPhone(phone);
}
// 2.判断id存在,则修改
if (ObjectUtil.isNotEmpty(member.getId())) {
updateById(member);
return;
}
// 3.id不存在,保存新用户
// 随机组装昵称,词组+手机号后四位
String nickName = DEFAULT_NICKNAME_PREFIX.get((int) (Math.random() * DEFAULT_NICKNAME_PREFIX.size()))
+ StringUtils.substring(member.getPhone(), 7);

member.setName(nickName);
save(member);
}

补充:

  • 修改MP自动填充拦截器

  • 对于小程序端的所有请求不走后台的用户,在新增或修改的时候,不需要自动填充创建人和修改人

    package com.zzyl.framework.interceptor;

    import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
    import com.zzyl.common.core.domain.model.LoginUser;
    import com.zzyl.common.utils.SecurityUtils;
    import lombok.SneakyThrows;
    import org.apache.commons.lang3.ObjectUtils;
    import org.apache.ibatis.reflection.MetaObject;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;

    import javax.servlet.http.HttpServletRequest;
    import java.util.Date;

    /**
    * MyBatis-Plus 自动填充处理器
    * 用于在插入和更新操作时自动填充创建时间、更新时间、创建人、更新人字段
    *
    * 注意:小程序端的所有请求不走后台用户认证,因此对于小程序端请求,
    * 不自动填充创建人(createBy)和更新人(updateBy),只填充时间字段。
    */
    @Component
    public class MyMetaObjectHandler implements MetaObjectHandler {

    @Autowired
    private HttpServletRequest request;

    /**
    * 判断当前请求是否需要排除自动填充(即不填充创建人/更新人)
    * 当前实现:如果请求URI以 "/member" 开头,则认为属于小程序端请求,不填充操作用户
    *
    * @return true 表示需要排除,false 表示正常填充
    */
    @SneakyThrows
    public boolean isExclude() {
    String requestURI = request.getRequestURI();
    // 小程序端请求路径通常包含 /member,这里作为识别标志
    if (requestURI.startsWith("/member")) {
    return true;
    }
    return false;
    }

    /**
    * 插入操作时的自动填充
    * – 总是填充创建时间(createTime)
    * – 只有当请求不是排除路径时,才填充创建人(createBy)
    */
    @Override
    public void insertFill(MetaObject metaObject) {
    // 固定填充创建时间为当前时间
    this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
    // 非小程序端请求,填充创建人ID(从当前登录用户获取)
    if (!isExclude()) {
    this.strictInsertFill(metaObject, "createBy", String.class, loadUserId() + "");
    }
    }

    /**
    * 更新操作时的自动填充
    * – 总是填充更新时间(updateTime)
    * – 只有当请求不是排除路径时,才填充更新人(updateBy)
    */
    @Override
    public void updateFill(MetaObject metaObject) {
    // 固定填充更新时间为当前时间
    this.setFieldValByName("updateTime", new Date(), metaObject);
    // 非小程序端请求,填充更新人ID(从当前登录用户获取)
    if (!isExclude()) {
    this.setFieldValByName("updateBy", loadUserId() + "", metaObject);
    }
    }

    /**
    * 获取当前登录用户的ID
    * 若无法获取登录用户(如未登录或异常),则返回默认值 1L
    * 注意:正常情况下后台管理端请求会携带登录用户信息,小程序端不填充用户字段,所以此方法返回值不影响小程序端
    *
    * @return 用户ID
    */
    private static Long loadUserId() {
    try {
    LoginUser loginUser = SecurityUtils.getLoginUser();
    if (ObjectUtils.isNotEmpty(loginUser)) {
    return loginUser.getUserId();
    }
    // 未获取到登录用户时,返回默认用户ID(可根据业务调整)
    return 1L;
    } catch (Exception e) {
    // 异常时返回默认用户ID,避免影响主流程
    return 1L;
    }
    }
    }

    4.5校验token

    4.5.1思路分析

    用户登录成功以后,会给前端返回一个token,用来验证用户信息,当用户点击了小程序中的其他操作(需要登录),则会把token携带到请求头(header)中,方便后台去验证并获取用户信息,简易流程如下

    如果想要验证用户的token,可以使用自定义的拦截器实现,整体的流程如下:

    4.5.2功能实现

    定义拦截器MemberInterceptor

    package com.zzyl.framework.interceptor;

    import cn.hutool.core.map.MapUtil;
    import cn.hutool.core.util.ObjectUtil;
    import com.zzyl.common.exception.base.BaseException;
    import com.zzyl.common.utils.StringUtils;
    import com.zzyl.common.utils.UserThreadLocal;
    import com.zzyl.framework.web.service.TokenService;
    import io.jsonwebtoken.Claims;
    import org.springframework.stereotype.Component;
    import org.springframework.web.method.HandlerMethod;
    import org.springframework.web.servlet.HandlerInterceptor;

    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;

    /**
    * 小程序端认证拦截器:解析Token并将userId存入ThreadLocal
    */
    @Component
    public class MemberInterceptor implements HandlerInterceptor {
    private final TokenService tokenService;

    public MemberInterceptor(TokenService tokenService) {
    this.tokenService = tokenService;
    }

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

    // 如果不是Controller层的请求,直接放行
    if (!(handler instanceof HandlerMethod)) {
    return true;
    }

    // 获取header中的token
    String token = request.getHeader("authorization");

    // 如果token为空,响应401,重新登录
    if (StringUtils.isEmpty(token)) {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    throw new BaseException("认证失败");
    }

    // 解析token
    Claims claims = tokenService.parseToken(token);
    if (ObjectUtil.isEmpty(claims)) {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    throw new BaseException("认证失败");
    }

    // 解析token中的数据
    Long userId = MapUtil.get(claims, "userId", Long.class);
    if (ObjectUtil.isEmpty(userId)) {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    throw new BaseException("认证失败");
    }

    // 将用户id放入ThreadLocal中
    UserThreadLocal.set(userId);

    return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    // 请求结束后清除线程本地变量
    UserThreadLocal.remove();
    }
    }

    找到ResourcesConfig类并修改,使拦截器生效(略)

    5.内容总结(仅作参考)

    5.1请简述 RBAC 权限模型的作用,如果不使用 RBAC 会存在什么问题?

    RBAC 是基于角色的访问控制模型,核心作用是用户 — 角色 — 权限解耦,不再直接给单个用户分配功能权限,而是先把多个功能权限打包封装成角色,再给用户分配对应角色。

  • 简化权限分配工作:当多个用户需要相同权限时,只需创建一次角色并分配权限,批量给用户绑定角色即可,不用逐个给用户配置功能;
  • 方便权限统一维护:如果某类岗位需要增减功能权限,只需要修改角色绑定的权限,所有拥有该角色的用户权限会同步更新,不用逐个修改用户;
  • 实现精细化权限管控,不同角色分配不同功能,实现不同人员分管不同业务。
  • 如果不使用 RBAC,需要直接给每一个用户逐个分配功能权限,一旦用户量多、单个用户拥有多个功能权限,权限配置工作量极大,后续权限调整维护繁琐,极易出现权限分配混乱、漏配、错配的问题。

    5.2结合项目说说 RBAC 涉及的业务表以及表之间的关系?

    本项目核心五张表:用户表、角色表、权限 / 资源表、部门表、职位表,外加两张中间表(用户角色中间表、角色权限中间表),都是多对多关系:

  • 用户与角色:多对多 一个用户可以拥有多个角色,一个角色可以分配给多个用户,通过sys_user_role中间表维护关联关系;
  • 角色与权限(菜单 / 按钮资源):多对多 一个角色可以绑定多个功能权限,一个权限可以赋予多个角色,通过sys_role_menu中间表维护;
  • 用户与部门:多对一 一个部门下有多个用户,一个用户仅归属一个部门;
  • 用户与职位:多对一 一个职位可以对应多名员工,一名用户只有一个职位。
  • 业务流程:用户归属部门、绑定职位→给用户分配角色→角色绑定菜单、按钮等功能权限,最终通过角色拿到用户拥有的所有权限。

    5.3 Spring Security 在若依项目中如何完成身份认证?简述认证流程

  • 用户前端输入用户名、密码登录,携带账号密码提交登录接口;
  • 框架通过自定义UserDetailsService实现类,根据用户名从数据库查询用户信息(账号、加密密码、角色、权限标识);
  • 使用BCryptPasswordEncoder密码加密器,将前端传入的明文密码加密后和数据库加密密码做比对;
  • 密码校验通过则认证成功,根据用户信息生成 JWT 令牌 Token 返回给前端;密码不一致、账号锁定、禁用等场景直接返回认证失败;
  • 本项目采用前后端分离无状态模式,关闭 Session,每次请求前端在请求头携带 Token 完成后续身份校验。
  • 5.4 Spring Security 如何实现授权与权限验证?项目中两种权限校验方式是什么?

    1. 授权流程

    认证成功后,会封装当前登录用户的权限标识、角色信息;给角色预先绑定菜单、按钮的权限标识符(如system:user:list),当前登录用户会获取自身所有角色下绑定的全部权限标识。

    2. 两种权限验证方式

  • URL 拦截鉴权:在SecurityFilterChain配置中,配置匿名放行接口、静态资源,其余所有接口必须登录认证后才能访问;
  • 方法级注解鉴权:开启@EnableMethodSecurity注解,在接口方法上使用@PreAuthorize("@ss.hasPermi('system:user:list')"),执行接口前校验当前登录用户是否拥有该权限标识,没有则直接拒绝访问。
  • 补充:配置中定义了AuthenticationManager认证管理器、JWT 过滤器、跨域过滤器、认证失败、退出登录处理器,所有请求先经过 JWT 过滤器解析 Token,完成身份识别后再做权限校验。

    5.5后端如何调用第三方接口?说说常用 HTTP 客户端以及 Hutool 中 Get、Post 请求的使用要点

  • 调用前提:先阅读第三方官方接口文档,明确接口四要素:请求地址、请求方式、请求入参、返回出参;后端通过发送 HTTP 请求调用第三方服务。
  • 常用 HTTP 客户端:Hutool 的HttpUtil、RestTemplate、OkHttp、OpenFeign。
  • 本项目使用 Hutool 工具类调用第三方接口:
    • GET 请求:参数直接拼接在 URL 后,可通过 Map 封装请求参数,支持携带请求头(如 Token 鉴权);
    • POST 请求:如果提交 JSON 格式参数,需要通过JSONUtil把实体转为 JSON 字符串,必须设置请求头Content-Type:application/json,同时可以携带鉴权 Token、请求超时时间,防止接口请求卡死;
    • 调用后先判断响应状态是否成功,再解析返回 JSON 数据,处理第三方返回的错误码与错误信息。

    5.6简述微信小程序登录整体开发思路,后端做了哪些核心工作?

  • 前端调用wx.login()获取临时登录凭证 code,调用微信获取手机号接口拿到手机号加密凭证 phoneCode,同时传递用户昵称传给后端登录接口;
  • 后端封装WechatService统一调用微信开放平台接口:
    • 通过 code 调用jscode2session接口,获取用户唯一标识 openId;
    • 先调用微信接口获取全局access_token,再凭借 phoneCode 调用接口拿到用户明文手机号;
  • 根据 openId 查询家属表family_member:
    • 不存在该用户:自动新建小程序用户,随机生成默认昵称(固定前缀 + 手机号后四位),保存 openId、手机号、昵称;
    • 用户已存在:校验手机号是否变更,更新最新手机号;
  • 将用户 ID、用户昵称存入 JWT 的载荷中,调用 Token 工具类生成令牌,把 Token 和用户昵称返回给前端,完成登录。
  • 针对小程序端请求,MyBatis-Plus 自动填充做了适配,以/member开头的请求不会自动填充创建人、更新人,只自动填充创建、更新时间。

    5.7开发微信登录时踩过哪些坑?如何解决?

  • 频繁调用微信access_token接口容易触发限流:解决方案是对 access_token 做缓存,设置有效期复用,不要每次获取手机号都重新请求;
  • 手机号为空时截取手机号后四位生成昵称会空指针:新增手机号非空校验,为空时设置默认昵称兜底;
  • 微信接口调用失败返回错误码:统一捕获异常,封装为自定义业务异常,向前端返回友好错误提示;
  • 5.8请说说小程序端 Token 校验的实现思路与拦截器核心代码逻辑,为什么要用 ThreadLocal?

    1. 实现思路

    用户登录成功后端返回 JWT 令牌,前端后续所有需要登录的接口,在请求头authorization中携带 Token;后端通过自定义拦截器MemberInterceptor统一拦截所有小程序接口,解析 Token 完成身份校验,未携带、过期、非法 Token 直接返回 401 未认证。

    2. 拦截器核心逻辑

  • 过滤非 Controller 请求(静态资源、视图等直接放行);
  • 从请求头获取 Token,为空直接抛出认证异常;
  • 调用 Token 工具类解析 Token,获取载荷中存储的小程序用户 userId;
  • 将 userId 存入ThreadLocal线程本地变量,方便后续业务层直接获取当前登录小程序用户;
  • 请求执行完毕后,在afterCompletion中清除 ThreadLocal 存储的数据,防止线程复用造成用户信息串号。
  • 3. ThreadLocal 作用

    多线程环境下,每个请求对应一个独立线程,ThreadLocal 可以实现线程内数据隔离,各个请求之间的登录用户互不干扰,不用在方法间反复传递用户 ID 参数,全局可以便捷获取当前登录用户。

    赞(0)
    未经允许不得转载:171主机测评 » 后台认证授权、小程序登录
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址