认证服务
环境搭建
创建gulimall-auth-server
模块并进行降版本处理
<version>2.1.8.RELEASE</version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
修改依赖:这里我们导入了公共服务common,但是认证服务模块用不到数据库,所以把Mybatis的依赖移除出去,否则会有数据源错误
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
将认证服务注册进nacos注册中心
- 在主启动类上添加启动注解:
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
- 在 application.properties 文件中编写认证服务的配置信息
spring.application.name=gulimall-auth-server
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
server.port=20000
动静资源配置
配置Nginx和网关
1、修改域名 vim /etc/hosts
# Gulimall Host Start
127.0.0.1 gulimall.com
127.0.0.1 search.gulimall.com
127.0.0.1 item.gulimall.com
127.0.0.1 auth.gulimall.com
# Gulimall Host End
2、配置网关
在 gulimall-gateway 服务下的 application.yml 文件下增加以下路由信息
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
- Host=auth.gulimall.com
动静分离
1、将登陆页面放入项目
将 /Users/hgw/Documents/Data/Project/谷粒商城/2.分布式高级篇/代码/html/登录页面
路径下的登录页面文件index.html 复制进gulimall-auth-server服务中的 gulimall-auth-server/src/main/resources/templates
路径下,并重新命名为 login.html
2、将注册页面放进项目
将 /Users/hgw/Documents/Data/Project/谷粒商城/2.分布式高级篇/代码/html/注册页面
路径下的注册页面文件index.html 复制进gulimall-auth-server服务中的 gulimall-auth-server/src/main/resources/templates
路径下,并重新命名为 reg.html
3、在服务器的nginx容器的/static静态资源目录下创建login和reg文件夹,然后将注册页面以及登录页面的静态资源分别复制进去。
hgw@HGWdeAir static % ll
total 0
drwxr-xr-x 5 hgw staff 160B 3 29 20:45 index
drwxr-xr-x 7 hgw staff 224B 4 5 10:19 item
drwxr-xr-x 5 hgw staff 160B 4 6 15:51 login
drwxr-xr-x 8 hgw staff 256B 4 6 15:51 reg
drwxr-xr-x 8 hgw staff 256B 4 2 14:09 search
hgw@HGWdeAir static % cd reg
hgw@HGWdeAir reg % ll
total 0
drwxrwxr-x@ 3 hgw staff 96B 12 18 2019 bootStrap
drwxrwxr-x@ 3 hgw staff 96B 3 22 2020 css
drwxrwxr-x@ 21 hgw staff 672B 3 22 2020 img
drwxrwxr-x@ 5 hgw staff 160B 3 22 2020 js
drwxrwxr-x@ 3 hgw staff 96B 3 22 2020 libs
drwxrwxr-x@ 5 hgw staff 160B 3 22 2020 sass
hgw@HGWdeAir reg % cd ../login
hgw@HGWdeAir login % ll
total 0
drwxrwxr-x@ 25 hgw staff 800B 3 22 2020 JD_img
drwxrwxr-x@ 4 hgw staff 128B 3 22 2020 JD_js
drwxrwxr-x@ 5 hgw staff 160B 3 22 2020 JD_sass
3、修改html的引用路径(Command+R)
-
Login.hteml
src=" --> src="/static/login/ href=" --> href="/static/login/
-
reg.html
src=" --> src="static/reg/ href=" --> href="static/reg/
优化页面的跳转环境
修改登录页和注册页达到点击登录页和注册页的谷粒商城logo可以跳到谷粒商城首页
1、先关闭页面的缓存
spring.thymeleaf.cache=false
2、修改login.html、reg.html 链接地址
<header>
<a href="http://gulimall.com"><img src="/static/login/JD_img/logo.jpg" /></a>
<p>欢迎登录</p>
<div class="top-1">
<img src="/static/login/JD_img/4de5019d2404d347897dee637895d02b_06.png" /><span>登录页面,调查问卷</span>
</div>
</header>
在首页点击登录和注册可以跳转到登录页面和注册页面
1、修改 gulimall-product 服务中的index.html 首页资源
<ul>
<li>
<a href="http://auth.gulimall.com/login.html">你好,请登录</a>
</li>
<li>
<a href="http://auth.gulimall.com/reg.html" class="li_2">免费注册</a>
</li>
<span>|</span>
<li>
<a href="/static/#">我的订单</a>
</li>
</ul>
2、在 gulimall-autu-server 服务中编写 Controller方法接收发送的动态请求
package com.atguigu.gulimall.auth.Controller;
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
@GetMapping("/reg.html")
public String regPage(){
return "reg";
}
}
发送一个请求直接跳转到一个页面并不赋值,我们原先是在controller里创建一个跳转页面的空方法。现在我们使用 SpringMVC viewController:将请求html页面映射过来,这样就无需写空方法了。
更多详情:
SpringBoot—WebMvcConfigurer详解_zhangpower1993的博客-CSDN博客_webmvcconfigurer
编写 GulimallWebConfig 类,代码如下:(并注释掉上面的LoginController类的处理动态请求的方法)
package com.atguigu.gulimall.auth.config;
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
/**
* @GetMapping("/login.html")
* public String loginPage(){
* return "login";
* }
*/
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
3、修改登录页面的立即注册链接地址
修改 login.html
<h5 class="rig">
<img src="/static/login/JD_img/4de5019d2404d347897dee637895d02b_25.png" />
<span><a href="http://auth.gulimall.com/reg.html">立即注册</a></span>
</h5>
4、修改注册页面的请登录链接地址
修改 reg.html
<div class="dfg">
<span>已有账号?</span>
<a href="http://auth.gulimall.com/login.html">请登录</a>
</div>
注册功能
验证码倒计时
需求:点击发送验证码开始60s倒计时功能
<a id="sendCode">发送验证码 </a>
$(function () {
$("#sendCode").click(function () {
// 2、进入倒计时效果
if($(this).hasClass("disabled")){
} else {
// 1、给指定手机号发送验证码
timeoutChangeStyle();
}
});
});
var num = 60;
function timeoutChangeStyle() {
$("#sendCode").attr("class","disabled");
if (num == 0) {
$("#sendCode").text("发送验证码");
num = 60;
$("#sendCode").attr("class","");
} else {
var str = num+"s 后再次发送";
$("#sendCode").text(str);
setTimeout("timeoutChangeStyle()",1000);
}
num--;
}
整合短信验证码
需求:点击发送验证码 并 开始倒计时功能
我们将短信验证码放在第三方服务中: gulimall-third-party
引入阿里云短信服务
1、:先购买阿里云的短信服务
2、下载文档中提到的HttpUtils,然后引入我们的服务中,这里是创建了一个utils包,然后把HttpUtils类放在这个包中
3、编写一个发送短信的组件,之后需要发送短信时直接调用这个组件的发送短信方法即可。
在 gulimall-third-party 服务中编写组件类 SmsComponent,这里为了不让阿里云的发送短信的配置信息写死,我们一样让其与配置文件关联起来,使用@ConfigurationProperties然后指定出前缀即可。
package com.atguigu.gulimall.thirdparty.component;
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component
public class SmsComponent {
private String host;
private String path;
private String appcode;
public void sendSmsCode(String phone,String code) {
String method = "POST";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
//根据API的要求,定义相对应的Content-Type
headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
Map<String, String> querys = new HashMap<String, String>();
Map<String, String> bodys = new HashMap<String, String>();
bodys.put("content", "code:"+code+",expire_at:2");
bodys.put("phone_number", phone);
bodys.put("template_id", "TPL_0001");
try {
HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys);
System.out.println(response.toString());
//获取response的body
//System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
}
}
}
4、编写配置
这里阿里云的很多服务调用都差不多,所以我们在调用其短信发送服务时需要指定其调用的哪个主机下的什么路径下的服务,然后再传入自己的appcode即可。
在 gulimall-third-party 服务中加入以下配置:
spring:
cloud:
alicloud:
sms:
host: https://dfsns.market.alicloudapi.com
path: /data/send_sms
appcode: 自己的appcode
5、编写Controller,提供远程调用接口
package com.atguigu.gulimall.thirdparty.controller;
@RestController
@RequestMapping("/sms")
public class SmsSendController {
@Autowired
SmsComponent smsComponent;
/**
* 提供给别的服务进行调用
* @param phone 手机号码
* @param code 验证码
* @return
*/
@GetMapping
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {
smsComponent.sendSmsCode(phone,code);
return R.ok();
}
}
认证服务远程调用发送短信功能
这里我们的发送短信功能是写在了第三方服务中,供其他服务进行远程调用的,而不是第三方服务接收请求然后自己调用本服务下的各个功能。所以这里我们要使用gulimall-auth-server 服务进行远程调用第三方服务的短信验证码功能,并且页面渲染。
1、编写远程调用接口
package com.atguigu.gulimall.auth.feign;
@FeignClient("gulimall-third-party")
public interface ThirdPartFeignService {
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
}
2、Controller层提供处理发送短信请求的接口
@Controller
public class LoginController {
@Autowired
ThirdPartFeignService thirdPartFeignService;
@ResponseBody
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone) {
String code = UUID.randomUUID().toString().substring(0, 5);
thirdPartFeignService.sendCode(phone,code);
return R.ok();
}
}
3、前端渲染
$(function () {
$("#sendCode").click(function () {
// 2、进入倒计时效果
if($(this).hasClass("disabled")){
// 正在倒计时
} else {
// 1、给指定手机号发送验证码
$.get("/sms/sendcode?phone="+$("#phoneNum").val())
timeoutChangeStyle();
}
});
});
短信验证码防刷
发送验证码的流程
前台页面向注册相关的微服务发送请求,再由该服务调用发送短信的第三方微服务,然后由短信服务发送短信。这时因为在发送验证码的时候,发送验证码的接口是对外暴露的,所以可能会被人恶意访问该接口,消耗短信资源,因此我们要加入redis实现接口防刷功能,不能随意让接口被调用。
准备工作:
- 引入Redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 在application配置文件中添加配置,指定redis的地址以及端口号
spring.redis.host=192.168.190.131
spring.redis.port=6379
防止60s内重复获取验证码的流程如下:
- 在 Redis 中以
phone-code
将电话号码和验证码进行存储并将当前时间与code一起存储 - 如果调用时以当前 phone 取出的 value 不为空且当前时间在存储时间的 60s 以内,说明 60s 内该号码已经调用过,返回错误信息
- 60s 以后再次调用,需要删除之前存储的 phone-code
- code 存在一个过期时间,我们设置为 10min,10min内验证该验证码有效
修改处理发送短信验证码的controller
@ResponseBody
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone) {
// TODO 1、接口防刷
// 通过手机号从redis中获取验证码,存: key:phone,value:code; sms:code:手机号 -> 验证码 并设置过期时间,防止同一个phone在60秒内再次发送验证码
String redisCode = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
// 判断redis是否保存过验证码
if (!StringUtils.isEmpty(redisCode) || !(redisCode == null)) {
long l = Long.parseLong(redisCode.split("_")[1]);
// 60秒内不能再发送短信验证码
if (System.currentTimeMillis() - l < 60000) {
return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(), BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());
}
}
// 如果没有设置过验证码或者发送验证码时间过了60s, 就重新生成验证码
String code = UUID.randomUUID().toString().substring(0, 5) + "_" + System.currentTimeMillis();
//向redis中设置key-value key: sms:code手机号 value: 验证码
redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, code, 10, TimeUnit.MINUTES);
// 使用OpenFeign远程调用第三方服务的接口,发送验证码
thirdPartFeignService.sendCode(phone, code.split("_")[0]);
return R.ok();
}
增加异常常量:因为验证码60s内重复发送属于用户操作错误,我们需要把这个错误信息返回给用户,因此在我们全局异常常量中增加此异常号与异常信息。
package com.atguigu.common.exception;
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
第三方相关服务controller
@Autowired
SmsComponent smsComponent;
@ResponseBody
@GetMapping("/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {
smsComponent.sendSmsCode(phone, code);
return R.ok();
}
前端页面修改
$(function () {
$("#sendCode").click(function () {
// 2、进入倒计时效果
if($(this).hasClass("disabled")){
// 正在倒计时
} else {
// 1、给指定手机号发送验证码
$.get("/sms/sendcode?phone="+$("#phoneNum").val(),function (data) {
if(data.code != 0) {
alert(data.msg);
}
})
timeoutChangeStyle();
}
});
});
注册功能的实现
先要考虑我们注册的流程:
先是对从前端传过来的表单数据进行接收,因此这里我们需要创建一个UserRegistVo
对这些数据进行封装(封装的同时要对其中的数据进行数据校验操作),然后需要判断校验信息中是否有错误,如果有错误就返回错误信息,并且请求重定向回注册页面。如果没有错误就先根据手机号去redis中获取验证码,如果获取的结果是null
说明验证码过期,需要重定向回注册页,如果获取的不是null
就判断验证码是否正确,正确就调用远程服务进行注册操作,并且删除验证码(令牌机制)。根据远程服务接口返回的信息判断是否注册成功,成功请求重定向到登录页面,否则重定向回到注册页面。
注册表单数据校验
注册时要提交的表单:
根据表单提交的数据封装一个后端VO用于接收表单数据,因为我们这个VO是接收的前端表单传过来的数据,所以不止前端部分需要进行校验,后端也要对表单数据进行校验,因此加入JSR303校验
规则。
1、使用JSR303校验要导入validation依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2、添加“com.atguigu.gulimall.auth.vo.UserRegistVo”类,代码如下:
@Data
public class UserRegistVo {
@NotEmpty(message = "用户名必须提交")
@Length(min = 6,max = 18,message = "用户名必须是6-18位字符")
private String userName;
@NotEmpty(message = "密码必须填写")
@Length(min = 6,max = 18,message = "密码必须是6-18位字符")
private String passWord;
@NotEmpty(message = "手机号必须填写")
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String code;
}
同时需要在接受数据的相关controller的方法参数上加入@Valid
注解并在参数中加入 BindingResult result
参数用来获取校验中出现的异常信息。
在gulimall-auth-server
服务中编写注册的主体逻辑
- 若JSR303校验未通过,则通过
BindingResult
封装错误信息,并转重定向注册页面 - 若通过JSR303校验,则需要从
redis
中取值判断验证码是否正确,正确的话通过会员服务注册 - 会员服务调用成功则重定向至登录页,否则封装远程服务返回的错误信息返回至注册页面
因此编写gulimall-auth-server
服务的Controller中的处理注册请求的方法:
/**
* @param vo
* @param result 利用session原理,将数据放在session中,只要跳到下一个页面的取出这个数据以后,session里面的数据就会被删掉
* @param redirectAttributes: 模拟重定向携带数据,因为model对于重定向请求无法传递数据
* @return
*/
@PostMapping("/register")
public String register(@Valid UserRegisterVo vo, BindingResult result, RedirectAttributes redirectAttributes){
if (result.hasErrors()){
//校验出错,转发到注册页,这里使用一个Map封装后台接收数据时数据校验中的错误信息
Map<String, String> map = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
redirectAttributes.addFlashAttribute("errors",map);
return "redirect:http://auth.gulimall.com/reg.html";
}
//真正注册,调用远程服务注册
//1、校验验证码
String code = vo.getCode();
String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
if (StringUtils.isNotBlank(s)){
if (code.equals(s.split("_")[0])){
//删除验证码;令牌机制
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
//验证码校验通过,真正注册,调用远程服务注册
R r = memberFeignService.regist(vo);
if (r.getCode()==0){
//成功,转到登录页
return "redirect:http://auth.gulimall.com/login.html";
}else{
Map<String,Object> map = new HashMap<>();
map.put("msg",r.get("msg"));
System.out.println(map);
redirectAttributes.addFlashAttribute("errors",map);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
Map<String,Object> map = new HashMap<>();
map.put("code","验证码输入错误");
redirectAttributes.addFlashAttribute("errors",map);
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
}else{
Map<String,Object> map = new HashMap<>();
map.put("code","验证码输入错误");
redirectAttributes.addFlashAttribute("errors",map);
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
}
这里的坑比较多:
- 比如最后是重定向就需要写清请求哪个服务器的什么资源(重定向相当于新访问一次,不会提交之前的表单数据),而**不能直接
return "reg"
**让SpringMVC对其进行视图解析,因为它是一次请求转发,浏览器url地址栏不会发生改变,那么如果刷新页面就会默认再提交一次数据。同时这里还有一个不能使用请求转发的点是,使用请求转发forward:/reg.html
:因为我们此次请求是一个post请求,而/reg.html接收的是一个get请求(我们使用路径映射,它默认是get),其不支持post请求,请求转发会原封不动的将请求转发给另一个页面。
总结:不能使用请求转发forward:/reg.html
的原因是我们配置了路径映射,/reg.html会被路径匹配到然后被SpringMVC进行解析然后返回”reg”然后被视图解析器解析然后返回reg.html页面,注意这里和我们直接return ”reg”
不一样的地方在于,第一种是我们自己要求的请求转发,即以当前请求的方式原封不动的转发下一次请求,所以是post请求,然后post请求被解析了。但是直接return ”reg”
就不一样了,这个请求会被视图解析器解析,然后由视图解析器进行转发,它默认的是get请求进行转发。
-
因为使用的重定向,那么就是两次请求,于是还用model的话第二次请求就获取不到第一次请求返回的model数据了,所以我们这里需要使用RedirectAttributes存储数据,这样下一次重定向的请求才可以拿到上一次请求存在里面返回的数据
-
重定向需要写全请求url,否则会由上一次请求发出的服务器为uri然后+/后面的地址进行重定向,大概意思就是重定向如果只写一个/reg那么不会访问gulimall/reg,而是会由本地主机地址+当前服务端口(127.0.0.1:20000)/reg进行重定向,所以重定向地址需要写清域名(这个域名会被nginx进行解析)。
-
我们这里最终是重定向到一个页面,返回的数据也封装到了RedirectAttributes,所以注意这里不要用@ResponseBody注解和@RestController注解,否则就不会走视图解析器而是给请求所在页面返回一个Json数据了。
-
RedirectAttributes的addFlashAttribute()方法是将errors保存在session中,一旦我们访问其他页面,这个数据就没了,因此后面我们需要使用分布式session。
RedirectAttributes
是用于重定向之后还能带参数跳转的的工具类,它有两种带参的方式:- 第一种:
redirectAttributes.addAttributie("prama",value);
这种方法相当于在重定向链接地址追加传递的参数,例如:
redirectAttributes.addAttributie("prama1",value1); redirectAttributes.addAttributie("prama2",value2); return:"redirect:/path/list"
同于
return:"redirect:/path/list?prama1=value1&prama2=value2"
,注意这种方法直接将传递的参数暴露在链接地址上,非常的不安全,慎用。- 第二种:
redirectAttributes.addFlashAttributie("prama",value);
这种方法是隐藏了参数,链接地址上不直接暴露,但是只能在重定向的“页面”获取参数值。其原理就是放到session中,session在跳到页面后马上移除对象。如果是重定向一个controller中是获取不到该prama属性值的。除非在controller中用@RequestPrama(value = "prama")String prama
注解,采用传参的方式获取参数值。
例如:
redirectAttributes.addFlashAttributie("prama1",value1); redirectAttributes.addFlashAttributie("prama2",value2); return:"redirect:/path/list.jsp"
- 第一种:
前端页面封装
修改 reg.html
<form action="/regist" method="post" class="one">
<div class="register-box">
<label class="username_label">用 户 名
<input name="userName" maxlength="20" type="text" placeholder="您的用户名和登录名">
</label>
<div class="tips" th:text="${errors!=null?errors.userName:''}">
</div>
</div>
<div class="register-box">
<label class="other_label">设 置 密 码
<input name="passWord" maxlength="20" type="password" placeholder="建议至少使用两种字符组合">
</label>
<div class="tips" th:text="${errors!=null?errors.passWord:''}">
</div>
</div>
<div class="register-box">
<label class="other_label">确 认 密 码
<input name="code" maxlength="20" type="password" placeholder="请再次输入密码">
</label>
<div class="tips">
</div>
</div>
<div class="register-box">
<label class="other_label">
<span>中国 0086∨</span>
<input name="phone" class="phone" id="phoneNum" maxlength="20" type="text" placeholder="建议使用常用手机">
</label>
<div class="tips" th:text="${errors!=null?errors.phone:''}">
</div>
</div>
<div class="register-box">
<label class="other_label">验 证 码
<input name="code" maxlength="20" type="text" placeholder="请输入验证码" class="caa">
</label>
<a id="sendCode">发送验证码 </a>
<div class="tips" th:text="${errors!=null?errors.code:''}">
</div>
</div>
<div class="arguement">
<input type="checkbox" id="xieyi"> 阅读并同意
<a href="static/reg/#">《谷粒商城用户注册协议》</a>
<a href="static/reg/#">《隐私政策》</a>
<div class="tips">
</div>
<br/>
<div class="submit_btn">
<button type="submit" id="submit_btn">立 即 注 册</button>
</div>
</div>
</form>
会员服务注册会员
因为认证服务只是负责注册页以及接收注册请求并收集数据以及检验数据的,真正注册的会员信息存储到数据库中进行持久化处理的工作是由会员服务实现的。所以认证服务中在对注册中数据检验全部正确之后会远程调用会员服务进行新注册会员信息的持久化处理。这里在调用远程会员服务之前需要对验证码进行验证,正确才会调用远程接口(代码在上面)。
在 gulimal-member服务中编写VO,因为需要接收认证服务传过来的数据,这里直接拷贝认证服务的VO即可。
package com.atguigu.gulimall.member.vo;
@Data
public class MemberRegistVo {
private String userName;
private String passWord;
private String phone;
}
Controller 层接口编写
com.atguigu.gulimall.member.controller
路径下的:MemberController 类
@PostMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo) {
try {
memberService.regist(vo);
} catch (PhoneExistException e) {
return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());
} catch (UserNameExistException e) {
return R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode(),BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
Service 层实现类编写
通过查看数据库表得到我们需要保存哪些会员基本信息:
- 会员等级:默认的会员等级我们需要通过查询 ums_member_level 表 获得
- 设置用户名和手机号:因为手机号用用户名是用来登录的,所以我们必须得保证它两都是唯一的才能进行注册。为了让Controller感知到手机号和用户名不唯一,我们采用异常机制。
- 密码加密存储
MemberServiceImpl实现类注册会员方法代码:
/**
* 注册会员
* @param vo
*/
@Override
public void regist(MemberRegistVo vo) {
MemberDao memberDao = this.baseMapper;
MemberEntity entity = new MemberEntity();
// 1、设置默认等级
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(levelEntity.getId());
// 2、 检查用户名和手机号是否唯一,为了让Controller感知异常,我们采用异常机制
checkPhoneUnique(vo.getPhone());
checkUserNameUnique(vo.getUserName());
// 唯一之后执行
entity.setMobile(vo.getPhone());
entity.setUsername(vo.getUserName());
// 3、密码要进行加密存储
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encode = bCryptPasswordEncoder.encode(vo.getPassWord());
entity.setPassword(encode);
// 其他的默认信息
// 保存
memberDao.insert(entity);
}
会员等级查询
// 1、设置默认等级
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(levelEntity.getId());
gulimall-member/src/main/resources/mapper/member/
路径下:MemberLevelDao.xml
<select id="getDefaultLevel" resultType="com.atguigu.gulimall.member.entity.MemberLevelEntity">
SELECT * FROM ums_member_level WHERE default_status=1;
</select>
异常机制(检查用户名和手机号是否唯一)
这里我们需要对用户名和手机号进行验证,看看是否数据库中已经注册过。如果注册过了我们就直接抛出异常,又因为我们需要返回是哪一个不唯一引起的异常,因此为它们各定义一个异常类,谁不唯一就抛出谁的异常
1、分别自定义用户名不唯一、手机号不唯一异常
在 gulimall-member 服务中创建 exception 包:
package com.atguigu.gulimall.member.exception;
public class UserNameExistException extends RuntimeException{
public UserNameExistException() {
super("用户名已存在");
}
}
package com.atguigu.gulimall.member.exception;
public class PhoneExistException extends RuntimeException {
public PhoneExistException() {
super("该手机号已被注册");
}
}
2、在Service接口层中增加判断 用户名不唯一、手机号不唯一的方法
public interface MemberService extends IService<MemberEntity> {
PageUtils queryPage(Map<String, Object> params);
void regist(MemberRegistVo vo);
void checkPhoneUnique(String phone) throws PhoneExistException;
void checkUserNameUnique(String userName) throws UserNameExistException;
}
3、在Service层实现类 MemberServiceImpl 中编写业务代码
/**
* 检查用手机号是否唯一
* @param phone 手机号;
* 有异常则不唯一,没抛出异常则正常进行
*/
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException {
MemberDao memberDao = this.baseMapper;
Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (count > 0){
throw new PhoneExistException();
}
}
/**
* 检查用用户名是否唯一
* @param userName 手机号;
* 有异常则不唯一,没抛出异常则正常进行
*/
@Override
public void checkUserNameUnique(String userName) throws UserNameExistException{
MemberDao memberDao = this.baseMapper;
Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("username", userName));
if (count>0) {
throw new UserNameExistException();
}
}
4、注册方法中进行调用
// 检查用户名和手机号是否唯一,为了让Controller感知异常,我们采用异常机制
checkPhoneUnique(vo.getPhone());
checkUserNameUnique(vo.getUserName());
5、在gulimall-common 服务中增加错误码列表
package com.atguigu.common.exception;
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
USER_EXIST_EXCEPTION(15001,"用户名已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已被注册");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
6、Controller层 MemberController 类进行异常处理,虽然这两个异常都是运行时异常,idea不会提示我们进行异常处理,但是我们这里需要捕捉异常,如果出现异常信息就返回给用户是哪一个属性值不唯一引起的异常。然后通过设置R的错误信息(错误码和错误信息)之后把R进行返回(成功不成功的信息都放在R中,只不过我们可以校验是否成功,然后通过R的不同方法比如成功是ok不成功是error来获取对应信息)。
@PostMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo) {
try {
memberService.regist(vo);
} catch (PhoneExistException e) {
return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());
} catch (UserNameExistException e) {
return R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode(),BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
密码加密处理
MD5&盐值&Bcrypt
MD5全称是Message-Digest Algorithm 5(信息摘要算法),属Hash算法
一类。MD5算法对输入任意长度的消息进行运行,产生一个128位的消息摘要(32位的数字字母混合码)。有以下四个特性:
- 压缩性:任意长度的数据,算出的MD5值长度都是固定的
- 容易计算:从原数据计算出MD5值很容易
- 抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别
- 强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的
MD5属于不可逆的明文加密算法,但是MD5并不安全,很多在线网站都可以通过使用彩虹表暴力破解MD5。
因此,需要通过使用MD5+盐值对密码进行加密,盐值就是一个随机生成的数
什么是加盐?
- 通过生成随机数和MD5生成字符串进行组合
- 数据库同时存储MD5值与salt值。验证正确性时使用salt与密码组合进行MD5,然后与数据库中的MD5进行匹配。
通过MD5Crypt就可以调用加盐方法:
方法1是加默认盐值: 1 1 1xxxxxxxx
方法2是加自定义盐值
缺点:还需额外在数据库中存储盐值
因此,可以使用Spring家的BCryptPasswordEncoder,它的encode()方法使用的就是MD5+盐值进行加密,盐值是随机产生的
-
Bcrypt简介: bcrypt是一种跨平台的文件加密工具。
-
由它加密的文件可在所有支持的操作系统和处理器上进行转移。它的口令必须是8至56个字符,并将在内部被转化为448位的密钥。
-
Bcrypt就是一款加密工具,可以比较方便地实现数据的加密工作。你也可以简单理解为它内部自己实现了随机加盐处理
-
例如,我们使用MD5加密,每次加密后的密文其实都是一样的,这样就方便了MD5通过大数据de的方式进行破解。
-
Bcrypt生成的密文是60位的。而MD5的是32位的。
-
Spring Security提供了BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密。
BCrypt强哈希方法,每次加密结果都不一样。
String encode(CharSequence rawPassword)
:传入铭文进行加密并返回boolean matches(CharSequence rawPassword, String encodedPassword)
:
传入铭文和密文进行判断是否一致!
-
因此在谷粒商城注册的业务逻辑中我们使用org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
包中的BCryptPasswordEncoder
,它会为我们生成随机盐值,并进行加密操作。比如我们两次用它加密123456都会返回一个不同的密文,但是我们用123456匹配它们都会成功,因为加密的盐值就在密文中,BCryptPasswordEncoder可以解析密文然后获取盐值进而和密码组合然后获得组合后的MD5然后与之前的MD5值进行匹配。
简单使用:
//密码进行加密存储
// 加密操作
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
memberEntity.setPassword(bCryptPasswordEncoder.encode(vo.getPassword()));
// 解密操作
String password = vo.getPassword();
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//maches方法,第一个参数是明文,第二个参数是密文,调用maches方过后会返回一个bool值
boolean matches = passwordEncoder.matches(password, memberEntity.getPassword());
controller层方法编写
会员服务(gulimall-member
)的controller:
@Transactional
@Override
public void regist(MemberRegistVo vo) {
MemberEntity memberEntity = new MemberEntity();
//设置默认等级
MemberLevelEntity levelEntity = this.baseMapper.getDefaultLevel();
memberEntity.setLevelId(levelEntity.getId());
//设置手机号
//预先检查用户名、手机号是否唯一,如果不唯一就会抛出异常
checkPhoneUnique(vo.getPhone());
checkUsernameUnique(vo.getUsername());
memberEntity.setMobile(vo.getPhone());
memberEntity.setUsername(vo.getUsername());
//密码进行加密存储
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
memberEntity.setPassword(bCryptPasswordEncoder.encode(vo.getPassword()));
save(memberEntity);
}
如果操作成功,会在数据库中查到一条新的数据,同时会从注册页面重定向到登录页面。(这里要注意我们所有的注册页面的跳转都要走我们设置的域名,然后通过域名访问到nginx然后nginx转发回我们的本地服务器的网关服务,必须走nginx的原因是我们所有页面的静态资源都在nginx中)。
登录功能
用户密码登陆
梳理流程:
先从前台login登录页面获取到登录的表单数据(账号/手机号和密码),然后远程调用会员服务进行登录逻辑的判断。
因为使用OpenFeign进行远程调用的时候,传递都是json格式的数据,所以要封装一个Vo对象对数据进行封装然后传输。同时要注意是Post请求方式。因为@RequestBody注解是获取的请求体数据。
最终会员服务service层的逻辑是根据传来的数据向数据库中查询数据,然后再controller层判断,如果获取到的数据时null,则封装错误信息并返回。
所以**gulimall-auth-server
服务中的登录的主体逻辑如下:**
- 该服务接收登录请求之后封装数据,再调用会员服务远程登录接口把接收到的封装后的数据传过去。
- 会员服务通过手机号或者用户名查询记录,如果为null表示该手机号或者用户名还没注册返回null,否则返回会员信息
- 然后取出会员信息的密码进行密码匹配
- 如果匹配成功,返回给认证服务一个R.ok(),然后认证服务重定向到商城首页
- 如果匹配失败,则封装错误信息并返回给认证服务错误信息,认证服务重定向到登录页并提示错误信息。
封装账号密码的登录VO
@Data
public class UserLoginVo {
private String loginacct;
private String password;
}
前端login.html页面提交登录数据
<form action="/login" method="post">
<ul>
<li class="top_1">
<img src="/static/login/JD_img/user_03.png" class="err_img1" />
<input type="text" name="loginacct" placeholder=" 邮箱/用户名/已验证手机" class="user" />
</li>
<li>
<img src="/static/login/JD_img/user_06.png" class="err_img2" />
<input type="password" name="password" placeholder=" 密码" class="password" />
</li>
<li class="bri">
<a href="static/login/index.html">忘记密码</a>
</li>
<li class="ent"><button class="btn2" type="submit"><a >登 录</a></button></li>
</ul>
</form>
编写认证服务的远程调用接口
1、编写feign远程调用登录接口
package com.atguigu.gulimall.auth.feign;
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
R regist(@RequestBody UserRegistVo vo);
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
}
2、编写Controller层 LoginController 类,该类用于处理前端发送的登录请求,主要是封装前端发送的数据(封装成VO),然后调用远程登录接口,接着根据远程调用结果进行成功与否的处理。这里请求不能加@RequestBody注解,因为是页面直接提交数据,数据类型是map并非json,而下面的会员服务的Controller需要加,因为下面调用远程接口是post请求。
@PostMapping("/login")
public String login(UserLoginVo vo,RedirectAttributes redirectAttributes) {
// 会员服务远程登录接口的调用
R login = memberFeignService.login(vo);
if(login.getCode()==0) {
// TODO 登录成功处理
return "redirect:http://gulimall.com";
} else {
// 账号密码出错
Map<String, String> errors = new HashMap<>();
errors.put("msg", login.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors", errors);
// 如果登录失败,重定向到登录页面并把错误数据进行返回
return "redirect:http://auth.gulimall.com/login.html";
}
}
编写远程登录接口(gulimall-member)
1、编写VO类用来接收认证服务传递过来的JSON数据
package com.atguigu.gulimall.member.vo;
@Data
public class MemberLoginVo {
private String loginacct;
private String password;
}
2、编写com.atguigu.gulimall.member.controller
路径下的 MemberController 类,这里使用@RequestBody接收认证服务传递过来的json数据
/**
* 账号密码登录
* @return
*/
@PostMapping("/login")
public R login(@RequestBody MemberLoginVo vo) {
MemberEntity entity = memberService.login(vo);
if (entity != null) {
return R.ok();
} else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
3、在 gulimall-common 服务中添加错误码信息
package com.atguigu.common.exception;
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
USER_EXIST_EXCEPTION(15001,"用户名已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已被注册"),
LOGINACCT_PASSWORD_INVAILD_EXCEPTION(15003,"账号或密码错误");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
4、Service层 MemberServiceImpl实现类登录方法编写
package com.atguigu.gulimall.member.service.impl;
/**
* 用户密码登录
* @param vo 用户 和 密码
* @return 如果为null,则失败。返回当前记录,则为真
*/
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginacct = vo.getLoginacct();
String password = vo.getPassword();
// 1、去数据库查询对应的记录
MemberDao memberDao = this.baseMapper;
// 只要手机号和密码任何一个匹配都查出来
MemberEntity entity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("username", loginacct).or().eq("mobile", loginacct));
if (entity == null) {
// 登录失败
return null;
} else {
// 2、获取到数据库的password
String passworDb = entity.getPassword();
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 3、密码匹配
boolean matches = passwordEncoder.matches(password, passworDb);
if (matches) {
return entity;
}else {
return null;
}
}
}
社交登录
Oauth2.0
简介:
OAuth简单说就是一种授权的协议,只要授权方和被授权方遵守这个协议去写代码提供服务,那双方就是实现了OAuth模式。OAuth2.0是OAuth协议的延续版本。OAuth 2.0关注客户端开发者的简易性。要么通过组织在资源拥有者和HTTP服务商之间的被批准的交互动作代表用户,要么允许第三方应用代表用户获得访问的权限。同时为Web应用,桌面应用和手机,和起居室设备提供专门的认证流程。
简单来说OAuth是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们的数据的内容。
其功能举例说明就是:我们想要登录CSDN但是还是没注册账号,因为注册账号比较麻烦所以想要更快捷的方式进行登录,所以可以选择QQ或者微博登录。
OAuth2.0:对于用户相关的 OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保存用户数据的安全和隐私,第三方网站访问用户数据前都需要显示向用户授权
-
Client :指第三方应用
-
Resource Owner :指用户
-
Authorization Server:是指我们的授权服务器
-
Resource Server :API服务器
流程图
微博认证登录
这里我们先去新浪微博开放平台创建我们的应用
找到微链接,点击网站接入
点击立即接入
填写个人信息
填写应用名称
点击我的应用
App Key 和 App Secret 是获取 Access token必填的参数
填写授权回调地址和授权失败回调地址
1. 流程
微博认证流程图:
大致流程就是通过我们的页面访问微博的登录页,登录成功后微博会根据我们填写的认证授权回调页,如:http://gulimall.com/success进行请求重定向,并且会在请求参数中携带一个code,我们的服务器后端根据这个code,加上App Key,App Secret,向微博获取AccessToken,随后,我们就可以通过微博提供的相关接口,利用AccessToken获取用户信息。
同时要注意code会过期,并且使用过一次后就会失效。
2. 修改前端页面
在前端登录页面增加一个微博log用于引导用户至微博的社交登录授权页
设定单击微博logo为发送Get请求,请求地址:
https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI
- YOUR_CLIENT_ID :自己的基本信息中的 App Key
- YOUR_REGISTERED_REDIRECT_URI:高级信息中的授权回调页
测试点击登录页上的微博图标,自动跳转到授权页面
<li>
<a href="https://api.weibo.com/oauth2/authorize?client_id=1643498253&response_type=code&redirect_uri=http://auth.gulimall.cn/oauth2.0/weibo/success">
<img style="height: 16px;width: 43px" src="/static/login/JD_img/weibo.png" />
</a>
</li>
此时如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE
CODE是我们用来换取令牌的参数,使用code换取access_token且只能换取一次,换取之后CODE就过期了即它只能使用一次。同一个用户的access_token一段时间内是不会变化的,即使我们多次换取。
换取token
POST请求:
https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE
- client_id :创建网站应用时的
app key
- client_secret :创建网站应用时的
app secret
- YOUR_REGISTERED_REDIRECT_URI :认证完成后的跳转链接(需要和平台高级设置一致)
- code :换取令牌的认证码
发送以上post请求之后微博的社交登录服务就会返回给我们一个微博的token,通过这个微博的Access token 可以获取微博开放的信息,比如用户的信息(性别、年龄等)。
获取用户信息
至于我们想要通过微博的Access token获取什么信息可以参考微博提供的信息查询接口文档,通过里面提供的openApi来获取信息。
这里比如我们获取用户信息:
-
根据用户ID获取用户信息
-
URL:https://api.weibo.com/2/users/show.json,post请求。
必选 类型及范围 说明 access_token true string 采用OAuth授权方式为必填参数,OAuth授权后获得。 uid false int64 需要查询的用户ID。 screen_name false string 需要查询的用户昵称。 -
返回结果: JSON
{ "id": 1404376560, "screen_name": "zaku", "name": "zaku", "province": "11", "city": "5", "location": "北京 朝阳区", "description": "人生五十年,乃如梦如幻;有生斯有死,壮士复何憾。", "url": "http://blog.sina.com.cn/zaku", "profile_image_url": "http://tp1.sinaimg.cn/1404376560/50/0/1", /.... }
项目整合社交登陆
商城项目整合社交登录之后社交登录时序图如下:
因为安全问题(第6步返回结果有acessID,sercet和token,这些数据需要保密),所以成功授权之后请求跳转地址改为我们的后台服务器,由后台服务器进行处理,不在前台(http://gulimall.cn/oauth2.0/weibo/success
)进行处理了
所以修改前端页面中微博logo的的请求跳转地址的redirect_uri属性值
<li>
<a href="https://api.weibo.com/oauth2/authorize?client_id=你的&response_type=code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success">
<img style="height: 16px;width: 43px" src="/static/login/JD_img/weibo.png" />
</a>
</li>
复制HttpUtils类
这里我们要用到HttpUtils类(之前第三方服务中也用到了)来发送Http请求封装的HttpUtils类进行发送请求,我们把这个类复制进 gulimall-common公共服务下,但这个类中用到了很多其他依赖,因此我们还需要把这些依赖引入。
1、为gulimall-common导入HttpUtils需要的依赖
<!--HttpUtils工具类需要使用的依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.12</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.3.7.v20160115</version>
</dependency>
2、将gulimall-third-party服务中的 HttpUtils类复制进 gulimall-common 服务下
public class HttpUtils {
public static HttpResponse doGet(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys)
....
}
...
}
登录认证接口编写
这里我们首先编写认证服务的处理社交登录请求之后的回调请求(该请求携带CODE)的Controller,需要先封装社交认证的请求信息,然后利用HttpUtils类发送Http请求换取token。
@Slf4j
@Controller
public class OAuth2Controller {
@Autowired
MemberFeignService memberFeignService;
/**
* 社交登陆回调
* @param code
* @return
* @throws Exception
*/
@GetMapping("/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code) throws Exception {
Map<String,String> header = new HashMap<>();
Map<String,String> query = new HashMap<>();
// 1、根据code换取accessToken
HashMap<String, String> map = new HashMap<>();
map.put("client_id", "1643498253");
map.put("client_secret", "71010dc8034f9073abe422d89337087e");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", header, query, map);
// 2、处理
if (response.getStatusLine().getStatusCode()==200) {
// 获取到了accessToken
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
// 知道当前是哪个社交用户
// 1)、当前用户如果是第一次登录则自动注册进来(为当前社交用户生成一个会员信息,以后这个社交账号就对应指定的会员)
// 登录或者注册
R oauth2Login = memberFeignService.oauth2Login(socialUser);
if (oauth2Login.getCode() == 0) {
// 3、登录成功提取信息并跳回首页
MemberRespVo data = oauth2Login.getData("data", new TypeReference<MemberRespVo>() {
});
log.info("登录成功:用户:{}",data.toString());
return "redirect:http://gulimall.com";
} else {
return "redirect:http://auth.gulimall.com/login.html";
}
} else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
}
会员服务的相关社交登录接口
这里我们需要为社交登录的用户进行验证,如果新用户就注册,老用户就直接登录。上面认证服务只是负责处理认证相关业务的,也就是说只是负责社交登录功能的执行,具体用户登录之后的状态还是由会员服务进行管理,因此上面认证服务的Controller还调用了会员服务的远程接口。
gulimall-member服务的远程社交登录接口编写
第一步、编写SocialUserVo,这里的SocialUserVo我们可以用和认证服务一样的SocialUserVo
package com.atguigu.gulimall.member.vo;
@Data
public class MemberSocialUser {
private String access_token;
private String remind_in;
private long expires_in;
private String uid;
private String isRealName;
}
第二步、修改 'ums_member’表结构,并修改实体类
添加三个以下3个属性
- social_uid :社交用户的唯一id
- access_token :社交登陆访问令牌
- expires_in :社交登陆访问令牌的过期时间
为 MemberEntity 类添加上对应的属性:
package com.atguigu.gulimall.member.entity;
@Data
@TableName("ums_member")
public class MemberEntity implements Serializable {
//前面本该有的属性省略...
/**
* 社交用户的唯一id
*/
private String socialUid;
/**
* 社交登陆访问令牌
*/
private String accessToken;
/**
* 社交登陆访问令牌的过期时间
*/
private Long expiresIn;
}
第三步、编写Service层实现类 MemberServiceImpl 社交登录方法,我们用了一下异常捕捉,因为使用HttpUtils.doGet发送get请求去微博获取用户信息并不一定成功,所以我们进行一下异常捕捉,并把uid相关信息(之前查出来了)放在外面,哪怕用户信息获取不到我们也要把此次社交登录结果向数据库中进行存储,只要保证uid传进去就可以了,因为每个社交登录用户的uid是固定的。
/**
* 社交登陆
* 第一次登录则是:注册+登录
* 非第一次登录即:登录
* @param socialUser
* @return
*/
@Override
public MemberEntity login(MemberSocialUser socialUser) throws Exception {
String uid = socialUser.getUid();
// 1、判断当前社交用户是否已经登录过系统
MemberDao memberDao = this.baseMapper;
MemberEntity memberEntity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
if (memberEntity != null) {
// 2、这个用户已经注册过
MemberEntity update = new MemberEntity();
update.setId(memberEntity.getId());
update.setAccessToken(socialUser.getAccess_token());
update.setExpiresIn(socialUser.getExpires_in());
// 需要修改 登录令牌 和 登录令牌的过期时间
memberDao.updateById(update);
memberEntity.setAccessToken(socialUser.getAccess_token());
memberEntity.setExpiresIn(socialUser.getExpires_in());
return memberEntity;
} else {
// 2、没有查到当前社交用户对应的记录,我们需要注册一个
MemberEntity regist = new MemberEntity();
try{
// 3、查询当前社交用户的社交账号信息(昵称,性别等)
Map<String,String> query = new HashMap<>();
query.put("access_token",socialUser.getAccess_token());
query.put("uid",socialUser.getUid());
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<String, String>(), query);
if (response.getStatusLine().getStatusCode() == 200) {
// 查询成功
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
// 当前社交账号的信息
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
String profile_image_url = jsonObject.getString("profile_image_url");
regist.setNickname(name);
regist.setGender("m".equals(gender)?1:0);
regist.setHeader(profile_image_url);
}
}catch (Exception e){
}
regist.setSocialUid(socialUser.getUid());
regist.setAccessToken(socialUser.getAccess_token());
regist.setExpiresIn(socialUser.getExpires_in());
memberDao.insert(regist);
// 设置默认等级
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
regist.setLevelId(levelEntity.getId());
return regist;
}
}
第四步、Controller层 MemberController类 实现方法编写
/**
* 社交登录
* @return
*/
@PostMapping("/oauth2/login")
public R oauth2Login(@RequestBody MemberSocialUser socialUser) throws Exception {
MemberEntity entity = memberService.login(socialUser);
if (entity != null) {
return R.ok().setData(entity);
} else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
第五步、gulimall-auth-server 服务编写feigin调用接口
package com.atguigu.gulimall.auth.feign;
@FeignClient("gulimall-member")
public interface MemberFeignService {
// ...
@PostMapping("/member/member/oauth2/login")
public R oauth2Login(@RequestBody SocialUser socialUser) throws Exception;
}
**总结:**先向微博发送请求获取认证用户的信息,获取到的信息有用户唯一的uid
,拿到用户信息后需要远程调用member服务的认证登录方法,在member认证登录方法中,需要先判断当前认证用户是否第一次登录,如果是第一次登录就要获取根据AccessToken获取认证用户的信息,同时创建一个新的MemberEntity
对象,此对象用作封装认证用户的相关信息,并且保存到数据库中,最后返回MemberEntity
。如果不是第一次登录,就要更新数据库中对应此用户的AccessToken和Expires_in(AccessToken过期时间),最后返回此用户的相关信息(通过MemberEntity
对象)。
问题: 认证登录成功后需要将登录的信息在商城首页进行回显,但是认证登录时的域名是auth.gulimall.com
,商城首页的域名是gulimall.com
,就涉及到了跨域数据无法共享的问题。
分布式Session
登录功能实现之后我们的前端页面出现了一个问题就是登录之后的用户信息无法回显
哪怕我们将用户信息放在session中前端页面也无法获取到这个用户信息,原因就是跨域之后请求的session数据无法共享,auth.gulimall域下的session作用域只限于auth.gulimall域,gulimall域是获取不到的,不共享的。因此下面我们引入分布式session。
Session原理
session
的本质是服务器内存中的一个对象,可以将session
看成一个Map集合。服务器中所有的session
都放在sessionManager
中进行管理。cookie 相当于一张银行卡,存在服务器的session相当于存储的现金,每次通过cookie 取出保存的数据:
分布式Session两大问题问题
在进行登录操作后,如果登录成功则会重定向到首页。但是登录成功的信息需要进行回显,就需要利用到session
来存放我们需要的信息。
session
是基于cookie
的,当客户端访问服务端的时候,服务端会向客户端颁发一个cookie
,cookie
中会保存一个JSSESSIONID
,第二次登录时,浏览器就会带着这个JSSESSIONID
去找到服务端内存中响应的Session,从而获取相关数据信息。但是这个存储这个存储JSSESSIONID
的cookie是有作用域(domain)的,默认就是当前域名
但是在分布式的场景下会出现问题:
- 问题一: 集群情况下session会失效,即同一个服务,如果复制多份,则session会出现数据不同步的问题,这里因为哪怕同一域名也可能因为负载均衡导致同一个用户两次登录访问到集群中的不同服务器,第二次访问的服务器没有之前登录的session(在集群的另一个服务器中存着)
- 问题二: 分布式情况下session不能跨域访问,即不同服务,session数据不能共享的问题
下面我们先分析解决问题一即集群情况下的session数据不同步问题
解决方案1:session复制 :不采用
解决方案2:客户端存储 :不采用
解决方案3:Hash一致性 :利用hash一致性,进行负载均衡,可以采用但是这里不采用
解决方案4:使用SpringSession: 统一存储,这里采用这套方案
接着我们解决问题2即不同服务(意味着不同域名)的Session不能共享问题:
解决方案为扩大第一次使用Sesssion时分配的cookie的作用域:指定cookie的作用域的域名为当前服务子域名的父域名,将name为jsessionid的cookie中的domain作用域设置为.gulimall.com
这样,auth.gulimall.com等子域名就都能共享session中的数据了
同时因为我们解决集群情况下session数据不同步问题的解决方案为引入redis来为session数据统一存储,那么不同服务访问session数据就也会去redis中取出session数据,解决了父域名访问不到不同服务器上的session数据问题(因为session数据都存在redis中了,所有人想用session数据都会去redis中取,session数据不在存在本地服务器了)
因此对于问题1和问题2,本项目解决方案如下:
- 后端统一存储,会员服务登录之后将Session存储进Redis。
- 前端一个卡统一去用,会员服务给浏览器发卡的时候,指定cookie的作用域为父域
但对于第二点中浏览器的cookie
是由我们的服务器默认颁发的,要想自定义颁发的cookie
内容,需要我们手动去修改,这会比较麻烦,因此我们引入SpringSession
,它封装了修改cookie的操作。
SpringSession
项目整合SpringSession
后端统一存储,会员服务登录之后将Session存储进Redis。
给 gulimall-product、gulimall-auth-server 两个服务都进行以下三步操作,因为登录功能在认证服务,但是登录之后重定向的首页在商品服务中。
- 引入依赖
<!-- 整合SpringSession解决session共享问题 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 配置application.properties
spring.session.store-type=redis # Session store type.
- 在配置类上标记注解
@EnableRedisHttpSession
@EnableRedisHttpSession
@EnableDiscoveryClient
@EnableFeignClients
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
到这里我们就可以使用SpringSession了,运行之前的登录代码,这里SpringSession默认使用jdk进行序列化,因此我们向session中存放的数据应该实现Serializable接口,否则就会报错,所以MemberRespVo implements Serializable
,然后测试成功之后就会发现redis中有数据了
自定义配置类
虽然前面我们已经实现了session数据统一存储到redis了,但是这里还有两个问题:
- SpringSession默认使用JDK序列化之后的数据存储到redis中(因为不能存对象,只能存对象),这里序列化结果并不好维护,因此我们需要存储json数据到redis中。
- 无法解决子域session无法共享的问题,我们需要设置保存jsessionid的cookie作用域为父域.gulimall.com而不是默认的当前作用域
因此这里我们自定义SpringSession的配置类来修改其默认配置信息以达到实现我们想要的效果。这里我们仍然需要在 gulimall-product和gulimall-auth-server 服务中都编写 GulimallSessionConfig 自定义配置类
@Configuration
public class SessionConfig {
/**
* 配置Cookie的信息,设置其作用域为父域名
通过修改CookieSerializer扩大session的作用域至**.gulimall.com
* @return
*/
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("gulimallsession"");
return cookieSerializer;
}
/**
* 配置Redis的序列化机制,将session对象转换为JSON格式
由于默认使用jdk进行序列化,通过导入RedisSerializer修改为json序列化
* @return
*/
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
SpringSession原理
这里启动SpringSession功能的就是我们在要使用SpringSession
的服务的主启动类上标记的**@EnableRedisHttpSession
注解**
@EnableRedisHttpSession
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(RedisHttpSessionConfiguration.class)// 导入配置
@Configuration
public @interface EnableRedisHttpSession {
@EnableRedisHttpSession
中导入了RedisHttpSessionConfiguration
这个配置类
而RedisHttpSessionConfiguration
这个配置类中为IOC容器中添加了一个组件: RedisOperationsSessionRepository
,这个组件用于使用redis操作session,也就是session的增删改查封装类。
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
SchedulingConfigurer {
RedisHttpSessionConfiguration
继承了SpringHttpSessionConfiguration
,
而SpringHttpSessionConfiguration
中为容器添加了另一个重要的组件:SessionRepositoryFilter
SessionRepositoryFilter
是session存储的过滤器。SessionRepositoryFilter
继承了OncePerRequestFilter
,而OncePerRequestFilter
实现了Filter
接口,所以**SessionRepositoryFilter
是一个servlet
的过滤器,所有的请求过来都需要经过filter
进行过滤。**
SessionRepositoryFilter
中重写了Filter最核心的方法: doFilterInternal
,这个方法在SessionRepositoryFilter
的父类中的doFilter
中被引用,也就是说是OncePerRequestFilter
重写了Fliter
接口的doFilter
,doFilterInternal
在doFilter
中被调用实现了核心的功能。
doFilterInternal
方法:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
// 将原生的request用一个包装类SessionRepositoryRequestWrapper包装起来
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
// 将原生的response用一个包装类包SessionRepositoryResponseWrapper装起来
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
try {
//最后在filter链中放行的是包装过后的request和response
filterChain.doFilter(wrappedRequest, wrappedResponse);
}
finally {
wrappedRequest.commitSession();
}
}
SpringSession
主要是使用了装饰器模式,将原生的request
和response
进行了封装,原来我们获取session
对象是用request.getSession()
获取,而现在是使用装饰过后的wrapperRequest
获取session,而这个装饰之后的request重写了getSession
方法,这个方法中获取session数据是从RedisOperationsSessionRepository
中获取Session
,这样我们对session的增删改查就是在Redis中进行的了。
// session是使用sessionRepository的createSession()方法进行创建的
S session = SessionRepositoryFilter.this.sessionRepository.createSession();
最后,在Redis中储存的Session过期时间也会自动延期。
核心原理总结:
@EnableRedisHttpSession
导入了RedisHttpSessionConfiguration
配置- 这个配置给容器中添加了一个组件
RedisOperationsSessionRepository
,这个组件是 Redis 操作 Session 的增删改查封装类。 - 这个配置还添加了一个SessionRepositoryFilter组件,它是 Session 存储过滤器,每个请求过来必须经过这个 Filter。
- 这个过滤器创建的时候,就自动从容器中获取到了上面的
RedisOperationsSessionRepository
- 核心原理——装饰者模式
原生request获取session时是通过HttpServletRequest
获取的
这里对request进行包装成了wrappedRequest
,并且重写了包装request的getSession()
方法
- 这个过滤器创建的时候,就自动从容器中获取到了上面的
页面效果完成
- 只要登录成功,缓存有用户数据,再点击登录链接,直接调转到首页;把GulimallWebConfig登录页的映射注释掉
- 只要登录成功,所有页面都有数据信息
在 Controller层 LoginController 类中编写登录页面跳转代码:防止我们登陆过之后如果还访问http://gulimall.com/login.html还会访问到登录页
@GetMapping("/login.html")
public String loginPage(HttpSession session) {
Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute == null) {
// 没登录,跳转到登录页
return "login";
} else {
// 登录过,重定向到首页
return "redirect:http://gulimall.com";
}
}
前面是社交登录,这里我们将按照账号密码登录登录成功也放入Session中!
1、账号密码登录成功之后,往Session中放入:session.setAttribute(AuthServerConstant.LOGIN_USER, data);
@PostMapping("/login")
public String login(UserLoginVo vo, RedirectAttributes redirectAttributes, HttpSession session) {
// 远处登录
R login = memberFeignService.login(vo);
if(login.getCode()==0) {
MemberRespVo data = login.getData("data", new TypeReference<MemberRespVo>() {
});
// 登录成功放到Session中
session.setAttribute(AuthServerConstant.LOGIN_USER, data);
return "redirect:http://gulimall.com";
} else {
// 验证码出错
Map<String, String> errors = new HashMap<>();
errors.put("msg", login.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors", errors);
// 如果校验错误,转发到注册页
return "redirect:http://auth.gulimall.com/login.html";
}
}
package com.atguigu.common.constant;
public class AuthServerConstant {
public static final String SMS_CODE_CACHE_PREFIX = "sms:code:";
//编写一个可修改的登录用户属性key,因为登录之后的用户信息唯一且整个项目都要用到,因此我们把它抽取出来作为一个常量放在common
public static final String LOGIN_USER = "loginUser";
}
2、MemberController 类中登录成功之后返回 MemberEntity
/**
* 账号密码登录
* @return
*/
@PostMapping("/login")
public R login(@RequestBody MemberLoginVo vo) {
MemberEntity entity = memberService.login(vo);
if (entity != null) {
return R.ok().setData(entity);
} else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
3、修改所有前端页面
<ul>
<li>
<a th:if="${session.loginUser!=null}" >Hi,[[${session.loginUser.nickname}]]</a>
<a href="http://auth.gulimall.com/login.html" th:if="${session.loginUser==null}" >你好,请登录</a>
</li>
<li>
<a th:if="${session.loginUser==null}" href="http://auth.gulimall.cn/reg.html" class="li_2">免费注册</a>
</li>
<span>|</span>
<li>
<a href="/static/#">我的订单</a>
</li>
</ul>
这里所有需要取出session(已经是分布式session了)数据的服务都需要配置redis和SpringSession。
单点登录
单点登录全称Single Sign On(简称SSO),是指在多系统应用群中只要登录其中一个系统,便可在其他所有系统中得到登录授权而无需再次登录,包括单点登录与单点注销两部分。
比如在微博登录过后,发现在微博的其他产品的网站都显示已经登录了,即便域名不一样。
单点登录实战
这里我们模拟一个中央认证服务器,然后模拟两个实现单点登录的子系统。
单点登录流程
-
客户端访问受保护的资源的时候
- 判断Session中是否有LoginUser
- 判断请求路径中是否有访问令牌token
- 如果上述都没有的情况,跳转到登录服务器SSOServer+redirectUrl地址(带上当前网址,为了后面登录后跳转)
return "redirect:"+ssoServer+"?redirect_url=http://client2.com:8082/employees";
-
SSOServer 登录服务器
-
首先判断cookie中是否有登录记录,如果cookie中有记录取出cookie对应的token值,带上访问令牌重新定向到 redirect_url
-
没有cookie则跳转到login.html等路页面输入登录信息
-
登录页面发送表单post请求登录,验证登录信息成功后
3.1 传一个UID作为key,value作为userId 将该键值队放入认证服务器等Cookie中在认证系统中记录登 录标记
Cookie sso_token = new Cookie("sso_token",token);
3.2 重定向到回调地址+访问令牌
return "redirect:"+url+"?token="+token;
-
这里第九步处理完登录请求要获取上一次的请求地址即从哪个地址重定向到中央认证服务器的,我们在认证服务器登录完成之后还要重定向回去,因此这里需要获取上一次请求的地址。然后第十步重定向回client1时又会因为没有登录而重定向到认证服务器,这里就陷入了死循环,因此我们第十步重定向回client1服务器时即认证服务器处理完登录业务(生成一个token,然后存放该次登录信息到分布式session中,key为token,value为用户信息)之后要在响应体中加一个token,这个token就表示此请求已经在认证服务器登录过了,之后client1处理登录请求时如果发现请求参数有token(这个参数不是必须的()@RequestParam的required属性值为false,比如第一次登录参数就没有token)就去分布式session(redis)查一下该token对应的用户信息,然后查成功之后就直接登录了,不需要再次登录了。
也就是说SSO服务器重定向回的请求其参数会多一个token参数,这就避免了子系统登录时陷入发送登录请求接收重定向请求的死循环。
登陆成功就会向session中保存登录状态信息
下面这个请求就是子系统需要登录之后才能返回正确数据的请求,因此先判断是否登录,没登录则先登录
下面这个请求是SSO处理登录请求,如果登录成功就会生成token并且把token返回给请求源以及要求请求源生成一个cookie存储这个token。
SSO单点登录核心原理:
首先需要有一个中央认证服务器,关键在于用户对一个服务进行访问并且进行登录操作,服务器会访问中央认证(SSO)服务器,并在SSO服务器留下登录痕迹,也就是服务器将登录的信息保存起来,生成一个token,并对浏览器进行发卡操作,就是生成一个cookie保存token信息,当用户再到其他服务进行登录时,浏览器就会带着cookie,而cookie中带有token的信息,服务器后台获取token信息,并携带token信息到中央认证服务器去获取用户的相关信息,从而实现单点登录功能。
这里最重要的一点就是如果系统给认证服务器发送登录请求成功,认证服务器会返回一个token,并且会给浏览器一个命令,要求存储一个cookie,cookie的值就是这个token(这个cookie的作用域就是认证服务器的域名),那么之后所有向认证服务器发送的请求都会携带这个cookie。
感谢耐心看到这里的同学,觉得文章对您有帮助的话希望同学们不要吝啬您手中的赞,动动您智慧的小手,您的认可就是我创作的动力!
之后还会勤更自己的学习笔记,感兴趣的朋友点点关注哦。