https://open.weixin.qq.com
(1)注册开发者账号:准备营业执照
(2)邮箱激活
(3)完善开发者资料
(4)开发者资质认证:1-2个工作日审批、300元
(5)创建网站应用:提交审核,7个工作日审批(免费)
(6)熟悉微信登录流程
参考文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
OAuth 2.0
是目前最流行的授权机制,用来授权第三方应用,获取用户数据。
OAuth 2.0 一词中的 “Auth” 表示 “授权”,字母 “O” 是 Open 的简称,表示 “开放” ,连在一起就表示 “开放授权”。这也是为什么我们使用 OAuth 的场景,通常发生在开放平台的环境下。
越来越多的第三方应用都在向用户提供使用微信登录的解决方案,来减少用户注册的繁琐操作。而这个解决方案的背后原理,也是我们要讲到的 OAuth 2.0 技术。
我住在一个大型的居民小区。
小区有门禁系统。
进入的时候需要输入密码。
我经常网购和外卖,每天都有快递员来送货。我必须找到一个办法,让快递员通过门禁系统,进入小区。
如果我把自己的密码,告诉快递员,他就拥有了与我同样的权限,这样好像不太合适。万一我想取消他进入小区的权力,也很麻烦,我自己的密码也得跟着改了,还得通知其他的快递员。
有没有一种办法,让快递员能够自由进入小区,又不必知道小区居民的密码,而且他的唯一权限就是送货,其他需要密码的场合,他都没有权限?
于是,我设计了一套授权机制。
第一步,门禁系统的密码输入器下面,增加一个按钮,叫做"获取授权"。快递员需要首先按这个按钮,去申请授权。
第二步,他按下按钮以后,屋主(也就是我)的手机就会跳出对话框:有人正在要求授权。系统还会显示该快递员的姓名、工号和所属的快递公司。
我确认请求属实,就点击按钮,告诉门禁系统,我同意给予他进入小区的授权。
第三步,门禁系统得到我的确认以后,向快递员显示一个进入小区的令牌(access token)。令牌就是类似密码的一串数字,只在短期内(比如七天)有效。
第四步,快递员向门禁系统输入令牌,进入小区。
有人可能会问,为什么不是远程为快递员开门,而要为他单独生成一个令牌?这是因为快递员可能每天都会来送货,第二天他还可以复用这个令牌。另外,有的小区有多重门禁,快递员可以使用同一个令牌通过它们。
我们把上面的例子搬到互联网,就是 OAuth
的设计了。
首先,居民小区就是储存用户数据的网络服务。比如,微信储存了我的好友信息,获取这些信息,就必须经过微信的"门禁系统"。
其次,快递员(或者说快递公司)就是第三方应用
,想要穿过门禁系统,进入小区。
最后,我就是用户本人,同意授权第三方应用进入小区,获取我的数据。
简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。
令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。
(1)令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。
(2)令牌可以被数据所有者撤销,会立即失效。以上例而言,屋主可以随时取消快递员的令牌。密码一般不允许被他人撤销。
(3)令牌有权限范围(scope),比如只能进小区的二号门。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。
上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点。
注意,只要知道了令牌,就能进入系统。系统一般不会再次确认身份,所以令牌必须保密,泄漏令牌与泄漏密码的后果是一样的。 这也是为什么令牌的有效期,一般都设置得很短的原因。
OAuth 的核心就是向第三方应用颁发令牌,OAuth 2.0 的标准是 RFC 6749 文件。由于互联网有多种场景,标准中定义了获得令牌的四种授权方式(authorization grant ):
注意,不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(AppID)
和客户端密钥(AppSecret)
。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。
参考文档:https://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html
微信登录使用了授权码方式
资料:资料>微信扫码登录>guigu_syt_user.sql
在service-user中添加依赖:
<dependencies> <!--实体--> <dependency> <groupId>com.atguigu</groupId> <artifactId>model</artifactId> <version>1.0</version> </dependency> <!--服务通用配置--> <dependency> <groupId>com.atguigu</groupId> <artifactId>service-util</artifactId> <version>1.0</version> </dependency> <!--自定义安全模块--> <dependency> <groupId>com.atguigu</groupId> <artifactId>spring-security</artifactId> <version>1.0</version> </dependency> <!--mysql驱动--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!-- 单元测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
找到service-util模块中的代码生成器,修改moduleName为user
,并执行,然后删除entity包,相关类中引入model模块中的类
在server-user模块中resources目录下创建文件
application.yml
:
spring: application: name: service-user profiles: active: dev,redis
application-dev.yml
:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: classpath:com/atguigu/syt/user/mapper/xml/*.xml server: port: 8203 spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 datasource: driver-class-name: com.mysql.cj.jdbc.Driver password: 123456 url: jdbc:mysql://localhost:3306/guigu_syt_user?characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false username: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 logging: level: root: info file: path: user feign: client: config: default: connect-timeout: 2000 #连接建立的超时时长,单位是ms,默认1s read-timeout: 2000 #处理请求的超时时间,单位是ms,默认为1s sentinel: enabled: true #开启Feign对Sentinel的支持 wx: open: app-id: wxc606fb748aedee7c # 微信开放平台 appid app-secret: 073e8e1117c1054b14586c8aa922bc9c #微信开放平台 appsecret redirect-uri: http://localhost:8200/api/user/wx/callback #微信开放平台 重定向url syt-base-url: http://localhost:3000 #预约挂号平台baserul
注意:此处重定向url的主机地址必须为 localhost:8200,因为这是在微信开放平台中预先配置的参数。生产环境中这个参数需要根据实际情况进行修改。
package com.atguigu.syt.user; @SpringBootApplication @ComponentScan(basePackages = {"com.atguigu"}) public class ServiceUserApplication { public static void main(String[] args) { SpringApplication.run(ServiceUserApplication.class, args); } }
创建utils包,创建ConstantProperties.java常量类
package com.atguigu.syt.user.utils; @Configuration @ConfigurationProperties(prefix="wx.open") //读取节点 @Data //使用set方法将wx.ope节点中的值填充到当前类的属性中 public class ConstantProperties { private String appId; private String appSecret; private String redirectUri; private String sytBaseUrl; }
避免红色提示:
在service的pom.xml中添加如下依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>
service-user微服务中创建controller.front包,front包中创建FrontWxController
package com.atguigu.syt.user.controller.front; @Api(tags = "微信扫码登录") @Controller//注意这里没有配置 @RestController @RequestMapping("/front/user/wx") @Slf4j public class FrontWxController { @Resource private ConstantProperties constantProperties; @GetMapping("login") public String login(HttpSession session){ try { StringBuffer baseUrl = new StringBuffer() .append("https://open.weixin.qq.com/connect/qrconnect") .append("?appid=%s") .append("&redirect_uri=%s") .append("&response_type=code") .append("&scope=snsapi_login") .append("&state=%s") .append("#wechat_redirect"); //处理回调url String redirectUri = URLEncoder.encode(constantProperties.getRedirectUri(), "UTF-8"); //处理state:生成随机数,存入session //ThreadLocalRandom解决了Random在高并发环境下随机数生成性能问题 long nonce = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE); //十六进制表示的随机数 String state = Long.toHexString(nonce); log.info("生成 state = " + state); session.setAttribute("wx_open_state", state); String qrcodeUrl = String.format( baseUrl.toString(), constantProperties.getAppId(), redirectUri, state ); return "redirect:" + qrcodeUrl; } catch (Exception e) { throw new GuiguException(ResultCodeEnum.URL_ENCODE_ERROR, e); } } }
在server-gateway中添加如下配置
- id: service-user predicates: Path=/*/user/** uri: lb://service-user
参考资料:https://baike.baidu.com/item/跨站请求伪造/13777878?fr=aladdin
跨站请求伪造,Cross-site request forgery,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法
跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
假如一家银行用以运行转账操作的URL地址如下:http://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName
那么,一个恶意攻击者可以在另一个网站上放置如下代码:
如果有账户名为Tom的用户访问了恶意站点,而他之前刚访问过银行不久,登录信息尚未过期,那么她就会损失1000资金。
添加校验token
由于CSRF的本质在于攻击者欺骗用户去访问自己设置的地址,所以如果要求在访问敏感数据请求时,要求用户浏览器提供不保存在cookie中,并且攻击者无法伪造的数据作为校验,那么攻击者就无法再运行CSRF攻击。这种数据通常是请求中的一个数据项。服务器将其生成并附加在请求中,其内容是一个伪随机数。当客户端提交请求时,这个伪随机数也一并提交上去以供校验。正常的访问时,客户端浏览器能够正确得到并传回这个伪随机数,而通过CSRF传来的欺骗性攻击中,攻击者无从事先得知这个伪随机数的值,服务端就会因为校验token的值为空或者错误,拒绝这个可疑请求。
注意:微信服务器配置授权回调域
要和redirect-uri
一致
service-user微服务中创建controller.api包,api包中创建ApiWxController
package com.atguigu.syt.user.controller.api; @Api(tags = "微信扫码登录回调") @Controller//注意这里没有配置 @RestController @RequestMapping("/api/user/wx") @Slf4j public class ApiWxController { /** * 登录回调 * @param code * @param state * @param session * @return */ @GetMapping("callback") public String callback(String code, String state, HttpSession session) { //得到授权临时票据code和state参数 log.info("callback被调用"); log.info("code = " + code); log.info("state = " + state); String sessionState = (String) session.getAttribute("wx_open_state"); log.info("sessionState = " + sessionState); log.info("seesion_id = " + session.getId()); if (StringUtils.isEmpty(code) || StringUtils.isEmpty(state) || !state.equals(sessionState)) { throw new GuiguException(ResultCodeEnum.ILLEGAL_CALLBACK_REQUEST_ERROR); } return null; } }
根据微信的openid判断数据库是否存在当前用户信息
接口:UserInfoService
/** * 根据openid查询用户信息 * @param openid * @return */ UserInfo getByOpenId(String openid);
实现:UserInfoServiceImpl
@Override public UserInfo getByOpenId(String openid) { LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(UserInfo::getOpenid, openid); return baseMapper.selectOne(queryWrapper); }
资料:资料>微信扫码登录>CookieUtils.java
放入service-util模块
package com.atguigu.syt.user.controller.api; @Api(tags = "微信扫码登录回调") @Controller//注意这里没有配置 @RestController @RequestMapping("/api/user/wx") @Slf4j public class ApiWxController { @Resource private ConstantProperties constantProperties; @Resource private UserInfoService userInfoService; @Resource private RedisTemplate redisTemplate; /** * 登录回调 * @param code * @param state * @param session * @return */ @GetMapping("callback") public String callback(String code, String state, HttpSession session, HttpServletResponse response) { try { //得到授权临时票据code和state参数 log.info("callback被调用"); log.info("code = " + code); log.info("state = " + state); String sessionState = (String) session.getAttribute("wx_open_state"); log.info("sessionState = " + sessionState); log.info("seesion_id = " + session.getId()); if (StringUtils.isEmpty(code) || StringUtils.isEmpty(state) || !state.equals(sessionState)) { throw new GuiguException(ResultCodeEnum.ILLEGAL_CALLBACK_REQUEST_ERROR); } //使用code和appid以及appscrect换取access_token StringBuffer baseAccessTokenUrl = new StringBuffer() .append("https://api.weixin.qq.com/sns/oauth2/access_token") .append("?appid=%s") .append("&secret=%s") .append("&code=%s") .append("&grant_type=authorization_code"); String accessTokenUrl = String.format(baseAccessTokenUrl.toString(), constantProperties.getAppId(), constantProperties.getAppSecret(), code); //使用httpclient发送请求 byte[] respdata = HttpUtil.doGet(accessTokenUrl); String result = new String(respdata); log.info("accesstokenInfo:" + result); JSONObject resultJson = JSONObject.parseObject(result); if (resultJson.getString("errcode") != null) { log.error("获取access_token失败:" + resultJson.getString("errcode") + resultJson.getString("errmsg")); throw new GuiguException(ResultCodeEnum.FETCH_ACCESSTOKEN_FAILD); } String accessToken = resultJson.getString("access_token"); String openId = resultJson.getString("openid"); log.info(accessToken); log.info(openId); //根据access_token获取微信用户的基本信息 //先根据openid进行数据库查询 UserInfo userInfo = userInfoService.getByOpenId(openId); if (userInfo != null) { //存在 log.info("判断用户是否被禁用"); if(userInfo.getStatus() == UserStatusEnum.LOCK.getStatus()){ log.error("用户已被禁用"); throw new GuiguException(ResultCodeEnum.LOGIN_DISABLED_ERROR); } }else{ log.info("注册用户"); //使用access_token换取受保护的资源:微信的个人信息 String baseUserInfoUrl = "https://api.weixin.qq.com/sns/userinfo" + "?access_token=%s" + "&openid=%s"; //使用httpclient发送请求 String userInfoUrl = String.format(baseUserInfoUrl, accessToken, openId); byte[] respdataUser = HttpUtil.doGet(userInfoUrl); String resultUserInfo = new String(respdataUser); JSONObject resultUserInfoJson = JSONObject.parseObject(resultUserInfo); if (resultUserInfoJson.getString("errcode") != null) { log.error("获取用户信息失败:" + resultUserInfoJson.getString("errcode") + resultUserInfoJson.getString("errmsg")); throw new GuiguException(ResultCodeEnum.FETCH_USERINFO_ERROR); } //解析用户信息 String nickname = resultUserInfoJson.getString("nickname"); String headimgurl = resultUserInfoJson.getString("headimgurl"); //用户注册 userInfo = new UserInfo(); userInfo.setOpenid(openId); userInfo.setNickName(nickname); userInfo.setHeadImgUrl(headimgurl); userInfo.setStatus(UserStatusEnum.NORMAL.getStatus()); userInfoService.save(userInfo); } //获取用户名,如果没有用户名(未实名认证),则获取昵称 String name = userInfo.getName(); if (StringUtils.isEmpty(name)) { name = userInfo.getNickName(); } //生成token String token = UUID.randomUUID().toString().replaceAll("-", ""); //将token做key,用户id做值存入redis redisTemplate.opsForValue()//30分钟 .set("user:token:" + token, userInfo.getId(), 30, TimeUnit.MINUTES); //将token和name存入cookie //将"资料>微信登录>CookieUtils.java"放入service-utils模块 int cookieMaxTime = 60 * 30;//30分钟 CookieUtils.setCookie(response, "token", token, cookieMaxTime); CookieUtils.setCookie(response, "name", URLEncoder.encode(name), cookieMaxTime); CookieUtils.setCookie(response, "headimgurl", URLEncoder.encode(userInfo.getHeadImgUrl()), cookieMaxTime); return "redirect:" + constantProperties.getSytBaseUrl(); } catch (GuiguException e) { log.error(ExceptionUtils.getStackTrace(e)); return "redirect:" + constantProperties.getSytBaseUrl() + "?code=201&message=" + URLEncoder.encode(e.getMsg()); } catch (Exception e) { log.error(ExceptionUtils.getStackTrace(e)); return "redirect:" + constantProperties.getSytBaseUrl() + "?code=201&message="+URLEncoder.encode("登录失败"); } } }
资料:资料>微信扫码登录>myheader.vue
将myheader.vue复制到前端项目的layouts目录中,覆盖原来的文件
前面我们使用了访问令牌和redis的形式代替session,也可以使用Spring提供的SpringSession实现session共享
开启SpringSession的步骤如下:
在service-user中添加spring-session依赖
<dependencies> <!--spring session--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> </dependencies>
在service-util的RedisConfig中添加如下配置:将默认的jdk序列化方案修改为json序列化方案
@Bean //(name = "springSessionDefaultRedisSerializer") public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }
上面的配置注入如下的位置
此时,之前我们存入session中的防止CSRF攻击的参数state,就可以自动存储在redis中了
在FrontWxController中添加测试代码
@GetMapping("testSaveSession") //在8203执行session存储 public void testSaveSession(HttpSession session){ session.setAttribute("user", "helen"); } @GetMapping("testGetSession") //在8213执行session获取 public void testGetSession(HttpSession session){ String user = (String)session.getAttribute("user"); log.info(user); }
源码:https://gitee.com/dengyaojava/guigu-syt-parent