【谷粒商城】【认证服务】验证码、社交登录、分布式session、单点登录

本篇9K字,请直接ctrl+F搜索内容

认证服务

一、gulimall-auth-server

创建gulimall-auth-server微服务,导入依赖,引入login.htmlreg.html,并把静态资源放到nginx的static目录下,修改hosts 192.168.56.10 auth.gulimall.com

网关配置启动起来即可

登录:http://auth.gulimall.com/

注册:http://auth.gulimall.com/reg.html

主页:http://gulimall.com/

注册页面controller

//如果controller只是跳转视图功能,可以直接注入controller
@Configuration
public class MyWebConfig implements WebMvcConfigurer {

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {

        registry.addViewController("/reg.html").setViewName("reg");
    }
}

二、验证码注册

(1) 验证码倒计时js

点击获取验证码后,进入倒计时

  • 计时功能可以使用js的timing计时时间,setTimeout()可以设置一段时间后执行代码
  • 递归回调可以解决倒计时刷新的功能
  • 开始倒计时后设置按钮不可用$("#sendCode").attr("class", "disabled")
$(function () {
    $("#sendCode").click(function () {
        if ($(this).hasClass("disabled")) {
            // 1.进入倒计时效果
        } else {
            $.get("/sms/sendcode?phone=" + $("#phoneNum").val(), function (data) {
                if (data.code != 0) {
                    layer.msg(data.msg)
                }
            });
            
            // 2.给指定手机号发送验证码
            timeoutChangeStyle()
        }
    })
})

// 外部变量计时
let num = 60;

function timeoutChangeStyle() {
    $("#sendCode").attr("class", "disabled")
    if (num == 0) {//可以再次发送
        num = 60;
        $("#sendCode").attr("class", "");//取消disabled
        $("#sendCode").text("发送验证码");
    } else {
        var str = num + "s 后再次发送";
        $("#sendCode").text(str);
        // 1s后回调
        setTimeout("timeoutChangeStyle()", 1000);
    }
    num--
}

短信发送的controller:

@ResponseBody
@GetMapping("/sms/snedcode")
public R sendCode(@RequestParam("phone") String phone){
    用短息服务给手机发送指定的内容,
        而该内容在redis中保存一份,
        所以带着验证码来的时候可以验证匹配到
        具体代码后面再写
(2) 阿里云-短信服务

https://market.aliyun.com/products/?keywords=短信

购买页面下有请求的url,点击去调试测试

请求参数:

名称类型是否必须描述
contentSTRING必选模板中变量名与参数值,多项值以","分隔
phone_numberSTRING必选手机号码
template_idSTRING必选模板ID

一般来说,就是html发送给java,java再发送给短信服务商。用户接收到验证码后,发送过来填写的验证码,进行验证。

逻辑:

  • 认证服务中短信controller接收到电话号请求后,认证服务生成一个验证码
  • 认证服务发送电话+验证码,调用第三方服务
  • 第三方服务调用短信服务商提供的接口,让短信服务商给手机发送生成好的验证码信息
  • 手机接收到验证码后,封装到账号信息中,发送给注册controller

gulimall-third-party中编写发送短信组件,其中hostpathappcode可以在配置文件中使用前缀spring.cloud.alicloud.sms进行配置

@Data
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Component
public class SmsComponent {

	private String host;

	private String path;

	private String skin;

	private String sign;

	private String appCode;

	public String sendSmsCode(String phone, String code){
		String method = "GET";
		Map<String, String> headers = new HashMap<String, String>();
		//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
		headers.put("Authorization", "APPCODE " + this.appCode);
		Map<String, String> querys = new HashMap<String, String>();
		querys.put("code", code);
		querys.put("phone", phone);
		querys.put("skin", this.skin);
		querys.put("sign", this.sign);
		HttpResponse response = null;
		try {
			response = HttpUtils.doGet(this.host, this.path, method, headers, querys);
			//获取response的body
			if(response.getStatusLine().getStatusCode() == 200){
				return EntityUtils.toString(response.getEntity());
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
		return "fail_" + response.getStatusLine().getStatusCode();
	}
}

编写controller,给别的服务提供远程调用发送验证码的接口

@Controller
@RequestMapping("/sms")
public class SmsSendController {

    @Autowired
    private SmsComponent smsComponent;

    /*** 提供给别的服务进行调用的
    该controller是发给短信服务的,不是验证的
	 */
    @GetMapping("/sendcode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code){
        if(!"fail".equals(smsComponent.sendSmsCode(phone, code).split("_")[0])){
            return R.ok();
        }
        return R.error(BizCodeEnum.SMS_SEND_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_SEND_CODE_EXCEPTION.getMsg());
    }
}

短信服务编写好后,我们在认证微服务中远程调用。

(3) 接口防刷(redis保存验证码)

为了防止恶意攻击短信接口,用redis缓存电话号

  • 在redis中以phone-code为前缀将电话号码和验证码进行存储并将当前时间与code一起存储
    • 如果调用时以当前phone取出的v不为空且当前时间在存储时间的60s以内,说明60s内该号码已经调用过,返回错误信息
    • 60s以后再次调用,需要删除之前存储的phone-code
    • code存在一个过期时间,我们设置为10min,10min内验证该验证码有效
@ResponseBody
@GetMapping("/sms/snedcode")
public R sendCode(@RequestParam("phone") String phone){

    //  接口防刷,redis缓存 sms:code:电话号
    String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
    // 如果不为空,返回错误信息
    if(null != redisCode && redisCode.length() > 0){
        long CuuTime = Long.parseLong(redisCode.split("_")[1]);
        if(System.currentTimeMillis() - CuuTime < 60 * 1000){ // 60s
            return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
        }
    }
    // 生成验证码
    String code = UUID.randomUUID().toString().substring(0, 6);
    String redis_code = code + "_" + System.currentTimeMillis();
    // 缓存验证码
    stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, redis_code , 10, TimeUnit.MINUTES);
    try {// 调用第三方短信服务
        return thirdPartFeignService.sendCode(phone, code);
    } catch (Exception e) {
        log.warn("远程调用不知名错误 [无需解决]");
    }
    return R.ok();
}
(4) 后端JSR303校验校验

前端也可以进行校验,此处是后端的验证

@Data
public class UserRegisterVo {// JSR303校验

	@Length(min = 6,max = 20,message = "用户名长度必须在6-20之间")
	@NotEmpty(message = "用户名必须提交")
	private String userName;

	@Length(min = 6,max = 20,message = "用户名长度必须在6-20之间")
	@NotEmpty(message = "密码必须提交")
	private String password;

	@NotEmpty(message = "手机号不能为空")
	@Pattern(regexp = "^[1]([3-9])[0-9]{9}$", message = "手机号格式不正确")
	private String phone;

	@NotEmpty(message = "验证码必须填写")
	private String code;
}

前面的JSR303校验怎么用:

JSR303校验的结果,被封装到BindingResult,再结合BindingResult.getFieldErrors()方法获取错误信息,有错误就重定向至注册页面

@PostMapping("/register")
public String register(@Valid UserRegisterVo registerVo, 
                       BindingResult result,
                       RedirectAttributes attributes) {

    if (result.hasErrors()){
        return "redirect:http://auth.gulimall.com/reg.html";

@ExceptionHandler @ControllerAdvice

(5) 注册用户保存

gulimall-auth-server服务中编写注册的主体逻辑

  • redis中确认手机验证码是否正确,一致则删除验证码,(令牌机制)
  • 会员服务调用成功后,重定向至登录页(防止表单重复提交),否则封装远程服务返回的错误信息返回至注册页面
  • 重定向的请求数据,可以利用RedirectAttributes参数转发
    • 但是他是利用的session原理,所以后期我们需要解决分布式的session问题
    • 重定向取一次后,session数据就消失了,因为使用的是.addFlashAttribute(
  • 重定向时,如果不指定host,就直接显示了注册服务的ip,所以我们重定义写http://…

注: RedirectAttributes可以通过session保存信息并在重定向的时候携带过去

@PostMapping("/register") // auth服务
public String register(@Valid UserRegisterVo registerVo,  // 注册信息
                       BindingResult result,
                       RedirectAttributes attributes) {
    //1.判断校验是否通过
    Map<String, String> errors = new HashMap<>();
    if (result.hasErrors()){
        //1.1 如果校验不通过,则封装校验结果
        result.getFieldErrors().forEach(item->{
            // 获取错误的属性名和错误信息
            errors.put(item.getField(), item.getDefaultMessage());
            //1.2 将错误信息封装到session中
            attributes.addFlashAttribute("errors", errors);
        });
        //1.2 重定向到注册页
        return "redirect:http://auth.gulimall.com/reg.html";
    }else {//2.若JSR303校验通过
        
        //判断验证码是否正确
        String code = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone());
        //2.1 如果对应手机的验证码不为空且与提交的相等-》验证码正确
        if (!StringUtils.isEmpty(code) && registerVo.getCode().equals(code.split("_")[0])) {
            //2.1.1 使得验证后的验证码失效
            redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone());

            //2.1.2 远程调用会员服务注册
            R r = memberFeignService.register(registerVo);
            if (r.getCode() == 0) {
                //调用成功,重定向登录页
                return "redirect:http://auth.gulimall.com/login.html";
            }else {
                //调用失败,返回注册页并显示错误信息
                String msg = (String) r.get("msg");
                errors.put("msg", msg);
                attributes.addFlashAttribute("errors", errors);
                return "redirect:http://auth.gulimall.com/reg.html";
            }
        }else {
            //2.2 验证码错误
            errors.put("code", "验证码错误");
            attributes.addFlashAttribute("errors", errors);
            return "redirect:http://auth.gulimall.com/reg.html";
        }
    }
}

验证短信验证码通过,下面开始去数据库保存

member远程服务

通过gulimall-member会员服务注册逻辑

  • 通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
  • 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
@RequestMapping("/register") // member
public R register(@RequestBody MemberRegisterVo registerVo) {
    try {
        memberService.register(registerVo);
        //异常机制:通过捕获对应的自定义异常判断出现何种错误并封装错误信息
    } catch (UserExistException userException) {//用户已存在
        return R.error(BizCodeEnum.USER_EXIST_EXCEPTION.getCode(), BizCodeEnum.USER_EXIST_EXCEPTION.getMsg());
    } catch (PhoneNumExistException phoneException) {// 手机已经注册
        return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(), BizCodeEnum.PHONE_EXIST_EXCEPTION.getMsg());
    }
    return R.ok();
}
  • 注册时检查用户名和手机唯一性
@Override // service
public void register(UserRegisterVo userRegisterVo)  throws PhoneExistException, UserNameExistException {

    MemberEntity entity = new MemberEntity();
    // 设置默认等级
    MemberLevelEntity memberLevelEntity = memberLevelDao.getDefaultLevel();
    entity.setLevelId(memberLevelEntity.getId());

    // 检查手机号 用户名是否唯一
    checkPhone(userRegisterVo.getPhone());
    checkUserName(userRegisterVo.getUserName());

    entity.setMobile(userRegisterVo.getPhone());
    entity.setUsername(userRegisterVo.getUserName());

    // 密码要加密存储
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    entity.setPassword(bCryptPasswordEncoder.encode(userRegisterVo.getPassword()));
    // 其他的默认信息
    entity.setCity("湖南 长沙");
    entity.setCreateTime(new Date());
    entity.setStatus(0);
    entity.setNickname(userRegisterVo.getUserName());
    entity.setBirth(new Date());
    entity.setEmail("xxx@gmail.com");
    entity.setGender(1);
    entity.setJob("JAVA");
    
    
    baseMapper.insert(entity);
}
@Override // void 无需bool // 自定义异常继承 extends RuntimeException
public void checkPhone(String phone) throws PhoneExistException{
    if(this.baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone)) > 0){
        throw new PhoneExistException();
    }
}

public class PhoneExistException extends RuntimeException {
    public PhoneExistException() {
        super("手机号存在");
    }
}
public class PhoneExistException extends RuntimeException{
    public PhoneExistException(){
        super("手机号已注册");
    }
}
(6)密码加密

java密码安全可以参考我之前的笔记:https://blog.csdn.net/hancoder/article/details/111464250

本文采样md5信息加密算法,但其实他不安全,可以加盐提高安全性Md5Crypt.md5Crypt(bytes,salt)

spring有个加密的BCryptPasswordEncoder.match()

(7) 用户名密码登录

gulimall-auth-server模块中的主体逻辑

  • 通过会员服务远程调用登录接口
    • 如果调用成功,重定向至首页
    • 如果调用失败,则封装错误信息并携带错误信息重定向至登录页
@RequestMapping("/login") // auth
public String login(UserLoginVo vo,RedirectAttributes attributes){
    // 远程服务
    R r = memberFeignService.login(vo);
    
    if (r.getCode() == 0) {
        return "redirect:http://gulimall.com/";
    }else {// 登录失败重回登录页面,携带错误信息
        String msg = (String) r.get("msg");
        Map<String, String> errors = new HashMap<>();
        errors.put("msg", msg);
        attributes.addFlashAttribute("errors", errors);
        return "redirect:http://auth.gulimall.com/login.html";
    }
}

gulimall-member模块中完成登录

  • 当数据库中含有以当前登录名为用户名或电话号且密码匹配时,验证通过,返回查询到的实体
  • 否则返回null,并在controller返回用户名或密码错误
@RequestMapping("/login") // member
public R login(@RequestBody MemberLoginVo loginVo) {
    MemberEntity entity=memberService.login(loginVo);
    if (entity!=null){
        return R.ok();
    }else {
        return R.error(BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getCode(), BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getMsg());
    }
}

@Override // service
public MemberEntity login(MemberLoginVo loginVo) {
    String loginAccount = loginVo.getLoginAccount();
    //以用户名或电话号登录的进行查询
    MemberEntity entity = this.getOne(new QueryWrapper<MemberEntity>().eq("username", loginAccount).or().eq("mobile", loginAccount));
    if (entity!=null){
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        boolean matches = bCryptPasswordEncoder.matches(loginVo.getPassword(), entity.getPassword());
        if (matches){
            entity.setPassword("");
            return entity;
        }
    }
    return null;
}

三、社交登录

社交登录指的是用QQ微信等方式登录

  • 点击QQ按钮
  • 引导跳转到QQ授权页
  • 用户主动点击授权,跳回之前网页
(1) OAuth2.0

上面社交登录的流程就是OAuth协议

OAuth(开放授权)是一个开放标准,允许用户授权第三方移动应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容,OAuth2.0是OAuth协议的延续版本,但不向后兼容OAuth 1.0即完全废止了OAuth1.0。

在这里插入图片描述

微信:https://developers.weixin.qq.com/doc/oplatform/Mobile_App/WeChat_Login/Development_Guide.html

客户端是

资源拥有者:用户本人

授权服务器:QQ服务器,微信服务器等。返回访问令牌

资源服务器:拿着令牌访问资源服务器看令牌合法性

1、使用Code换取AccessToken,Code只能用一次
2、同一个用户的accessToken一段时间是不会变化的,即使多次获取

(2) 微博开放平台使用

https://open.weibo.com/authentication

https://open.weibo.com/connect 点击网站接入

填写一些个人信息后,https://open.weibo.com/apps/new?sort=web 创建新应用gulimallxxx,会得到APP KEYAPP Secret

在高级信息里填写

  • 授权回调页:gulimall.com/success
  • 取消授权回调页:gulimall.com/fail

https://open.weibo.com/wiki/授权机制说明 查看OAuth2

img

\1. 引导需要授权的用户到如下地址:

https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=授权后跳转的uri

示例:
https://api.weibo.com/oauth2/authorize?
client_id=刚才申请的APP-KEY &
response_type=code&
redirect_uri=http://gulimall.com/success

\2. 如果用户同意授权(输入账号密码),带着code,页面跳转至 gulimall.com/success/?code=CODE

跳回我们网站的时候,带了一个code码,这个code码可以理解为用户登录的sessionID

\3. POST拿着code码换取Access Token

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=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET可以使用basic方式加入header中,返回值

{
    "access_token": "SlAV32hkKG",
    "remind_in": 3600, # 也是声明周期,但将废弃
    "expires_in": 3600 # access_token的生命周期;
}

\4. 使用获得的Access Token调用API,可以获取头像等信息 https://open.weibo.com/wiki/2/users/show

结果返回json

(3) 代码编写

注意点:

  • 登录成功得到了code,这不应该提供给用户
  • 拿着code还有其他信息APP-KEY去获取token,更不应该给用户看到
  • 应该回调的是后台的controller,在后台处理完token逻辑后返回
  • 把成功后回调改为:gulimall.com/oauthn2.0/weibo/success
/weibo/success
  • 通过HttpUtils发送请求获取token,并将token等信息交给member服务进行社交登录
    • 进行账号保存,主要有uid、token、expires_in
  • 若获取token失败或远程调用服务失败,则封装错误信息重新转回登录页

登录成功跳转到首页,但是怎么保证没有验证情况下访问不了首页:用shiro等拦截器功能

@GetMapping("/weibo/success") // Oath2Controller
public String weiBo(@RequestParam("code") String code, HttpSession session) throws Exception {

    // 根据code换取 Access Token
    Map<String,String> map = new HashMap<>();
    map.put("client_id", "1294828100");
    map.put("client_secret", "a8e8900e15fba6077591cdfa3105af44");
    map.put("grant_type", "authorization_code");
    map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
    map.put("code", code);
    Map<String, String> headers = new HashMap<>();

    // 去获取token
    HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, null, map);
    if(response.getStatusLine().getStatusCode() == 200){
        // 获取响应体: Access Token
        String json = EntityUtils.toString(response.getEntity());
        SocialUser socialUser = JSON.parseObject(json, SocialUser.class);

        // 相当于我们知道了当前是那个用户
        // 1.如果用户是第一次进来 自动注册进来(为当前社交用户生成一个会员信息 以后这个账户就会关联这个账号)
        R login = memberFeignService.login(socialUser);
        if(login.getCode() == 0){
            MemberRsepVo rsepVo = login.getData("data" ,new TypeReference<MemberRsepVo>() {});

            log.info("\n欢迎 [" + rsepVo.getUsername() + "] 使用社交账号登录");
            // 第一次使用session 命令浏览器保存这个用户信息 JESSIONSEID 每次只要访问这个网站就会带上这个cookie
            // 在发卡的时候扩大session作用域 (指定域名为父域名)
            // TODO 1.默认发的当前域的session (需要解决子域session共享问题)
            // TODO 2.使用JSON的方式序列化到redis
            //				new Cookie("JSESSIONID","").setDomain("gulimall.com");
            session.setAttribute(AuthServerConstant.LOGIN_USER, rsepVo);
            // 登录成功 跳回首页
            return "redirect:http://gulimall.com";
        }else{
            return "redirect:http://auth.gulimall.com/login.html";
        }
    }else{
        return "redirect:http://auth.gulimall.com/login.html";
    }
}
token保存
  • 登录包含两种流程,实际上包括了注册和登录
  • 如果之前未使用该社交账号登录,则使用token调用开放api获取社交账号相关信息(头像等),注册并将结果返回
  • 如果之前已经使用该社交账号登录,则更新token并将结果返回
@RequestMapping("/oauth2/login")
public R login(@RequestBody SocialUser socialUser) {
    MemberEntity entity=memberService.login(socialUser);
    if (entity!=null){
        return R.ok().put("memberEntity",entity);
    }else {
        return R.error();
    }
}

@Override // 已经用code生成了token
public MemberEntity login(SocialUser socialUser) {

    // 微博的uid
    String uid = socialUser.getUid();
    // 1.判断社交用户登录过系统
    MemberDao dao = this.baseMapper;
    MemberEntity entity = dao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));

    MemberEntity memberEntity = new MemberEntity();
    if(entity != null){ // 注册过
        // 说明这个用户注册过, 修改它的资料
        // 更新令牌
        memberEntity.setId(entity.getId());
        memberEntity.setAccessToken(socialUser.getAccessToken());
        memberEntity.setExpiresIn(socialUser.getExpiresIn());
        // 更新
        dao.updateById(memberEntity);

        entity.setAccessToken(socialUser.getAccessToken());
        entity.setExpiresIn(socialUser.getExpiresIn());
        entity.setPassword(null);
        return entity;
    }else{ // 没有注册过
        // 2. 没有查到当前社交用户对应的记录 我们就需要注册一个
        HashMap<String, String> map = new HashMap<>();
        map.put("access_token", socialUser.getAccessToken());
        map.put("uid", socialUser.getUid());
        try {
            // 3. 查询当前社交用户账号信息(昵称、性别、头像等)
            HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), map);
            if(response.getStatusLine().getStatusCode() == 200){
                // 查询成功
                String json = EntityUtils.toString(response.getEntity());
                // 这个JSON对象什么样的数据都可以直接获取
                JSONObject jsonObject = JSON.parseObject(json);
                memberEntity.setNickname(jsonObject.getString("name"));
                memberEntity.setUsername(jsonObject.getString("name"));
                memberEntity.setGender("m".equals(jsonObject.getString("gender"))?1:0);
                memberEntity.setCity(jsonObject.getString("location"));
                memberEntity.setJob("自媒体");
                memberEntity.setEmail(jsonObject.getString("email"));
            }
        } catch (Exception e) {
            log.warn("社交登录时远程调用出错 [尝试修复]");
        }
        memberEntity.setStatus(0);
        memberEntity.setCreateTime(new Date());
        memberEntity.setBirth(new Date());
        memberEntity.setLevelId(1L);
        memberEntity.setSocialUid(socialUser.getUid());
        memberEntity.setAccessToken(socialUser.getAccessToken());
        memberEntity.setExpiresIn(socialUser.getExpiresIn());

        // 注册 -- 登录成功
        dao.insert(memberEntity);
        memberEntity.setPassword(null);
        return memberEntity;
    }
}

四、分布式session

(1) session 原理

session存储在服务端,jsessionId存在客户端,每次通过jsessionid取出保存的数据

问题:但是正常情况下session不可跨域,它有自己的作用范围

这个session被sessionManager管理着

JsessionId列说明
ValueXXXXXX…
Domaingulimall.com要放大域名作用域
Path/
Expires/Max-Age40
(2) 分布式session解决方案

session要能在不同服务和同服务的集群的共享

1) session复制

用户登录后得到session后,服务把session也复制到别的机器上,显然这种处理很不好

2) hash一致性

根据用户,到指定的机器上登录。但是远程调用还是不好解决

3) redis统一存储

最终的选择方案,把session放到redis中

(3) SpringSession整合redis

https://spring.io/projects/spring-session-data-redis

https://docs.spring.io/spring-session/docs/2.4.2/reference/html5/#modules

通过SpringSession修改session的作用域

会员服务、订单服务、商品服务,都是去redis里存储session

1) 环境搭建

Oauth导入依赖

<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>

修改配置

spring.session.store-type=redis
server.servlet.session.timeout=30m
spring.redis.host=192.168.56.10

添加注解

@EnableRedisHttpSession //创建了一个springSessionRepositoryFilter ,负责将原生HttpSession 替换为Spring Session的实现
public class GulimallAuthServerApplication {

但是现在还有一些问题:

  • 序列化的问题
  • cookie的domain的问题
2) 扩大session作用域
  • 由于默认使用jdk进行序列化,通过导入RedisSerializer修改为json序列化

  • 并且通过修改CookieSerializer扩大session的作用域至**.gulimall.com

@Configuration
public class GulimallSessionConfig {

    @Bean // redis的json序列化
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

    @Bean // cookie
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer serializer = new DefaultCookieSerializer();
        serializer.setCookieName("GULISESSIONID"); // cookie的键
        serializer.setDomainName("gulimall.com"); // 扩大session作用域,也就是cookie的有效域
        return serializer;
    }
}

把这个配置放到每个微服务下

(4) SpringSession核心原理 - 装饰者模式

网上百度一下:https://blog.csdn.net/m0_46539364/article/details/110533408

就是分析@EnableRedisHttpSession,

@Import({RedisHttpSessionConfiguration.class})
@Configuration( proxyBeanMethods = false)
public @interface EnableRedisHttpSession {
public class RedisHttpSessionConfiguration 
    extends SpringHttpSessionConfiguration // 继承
    implements 。。。{
    
    // 后面SessionRepositoryFilter会构造时候自动注入他
    @Bean // 操作session的方法,如getSession()  deleteById()
    public RedisIndexedSessionRepository sessionRepository() {

SessionRepositoryFilter,每个请求都要经过该filter

public class SpringHttpSessionConfiguration 
    implements ApplicationContextAware {

    @Bean
    public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(SessionRepository<S> sessionRepository) { // 注入前面的bean
        SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter(sessionRepository);
        sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
        return sessionRepositoryFilter;
    }

前面我们@Bean注入了sessionRepositoryFilter,他是一个过滤器,那我们需要知道他过滤做了什么事情:

  • 原生的获取session时是通过HttpServletRequest获取的
  • 这里对request进行包装,并且重写了包装request的getSession()方法
@Override // SessionRepositoryFilter.java
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response, 
                                FilterChain filterChain) {
    
    request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);

    //对原生的request、response进行包装
    // SessionRepositoryRequestWrapper.getSession()
    SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
        request, response, this.servletContext);
    SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
        wrappedRequest, response);

    try {
        filterChain.doFilter(wrappedRequest, wrappedResponse);
    }
    finally {
        wrappedRequest.commitSession();
    }
}

绣花前面的代码,controller层加参数HttpSession,直接session.setAttribute(“user”,user)即可

前端页面的显示可以用<li th:if="${session.loginUser} ==null">

(5)session的保存
@GetMapping({"/login.html","/","/index","/index.html"}) // auth
public String loginPage(HttpSession session){
    // 从会话从获取loginUser
    Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);// "loginUser";
    System.out.println("attribute:"+attribute);
    if(attribute == null){
        return "login";
    }
    System.out.println("已登陆过,重定向到首页");
    return "redirect:http://gulimall.com";
}


@PostMapping("/login") // auth
public String login(UserLoginVo userLoginVo,
                    RedirectAttributes redirectAttributes,
                    HttpSession session){
    // 远程登录
    R r = memberFeignService.login(userLoginVo);
    if(r.getCode() == 0){
        // 登录成功
        MemberRespVo respVo = r.getData("data", new TypeReference<MemberRespVo>() {});
        // 放入session  // key为loginUser
        session.setAttribute(AuthServerConstant.LOGIN_USER, respVo);//loginUser
        log.info("\n欢迎 [" + respVo.getUsername() + "] 登录");
        // 登录成功重定向到首页
        return "redirect:http://gulimall.com";
    }else {
        HashMap<String, String> error = new HashMap<>();
        // 获取错误信息
        error.put("msg", r.getData("msg",new TypeReference<String>(){}));
        redirectAttributes.addFlashAttribute("errors", error);
        return "redirect:http://auth.gulimall.com/login.html";
    }
}

分布式登录总结

登录url:http://auth.gulimall.com/login.html
(注意是url,不是页面。)
判断session中是否有user对象

  • 没有user对象,渲染login.html页面
    用户输入账号密码后发送给 url:auth.gulimall.com/login
    根据表单传过来的VO对象,远程调用memberFeignService验证密码
    • 如果验证失败,取出远程调用返回的错误信息,放到新的请求域,重定向到登录url
    • 如果验证成功,远程服务就返回了对应的MemberRespVo对象,
      然后放到分布式redis-session中,key为"loginUser",重定向到首页gulimall.com,
      同时也会带着的GULISESSIONID
      • 重定向到非auth项目后,先经过拦截器看session里有没有loginUser对象
      • 有,放到静态threadLocal中,这样就可以操作本地内存,无需远程调用session
      • 没有,重定向到登录页
  • 有user对象,代表登录过了,重定向到首页,session数据还依靠sessionID持有着

额外说明:

问题1:我们有sessionId不就可以了吗?为什么还要在session中放到User对象?
为了其他服务可以根据这个user查数据库,只有session的话不能再次找到登录session的用户

问题2:threadlocal的作用?

他是为了放到当前session的线程里,threadlocal就是这个作用,随着线程创建和消亡。把threadlocal定义为static的,这样当前会话的线程中任何代码地方都可以获取到。如果只是在session中的话,一是每次还得去redis查询,二是去调用service还得传入session参数,多麻烦啊

问题3:cookie怎么回事?不是在config中定义了cookie的key和序列化器?

序列化器没什么好讲的,就是为了易读和来回转换。而cookie的key其实是无所谓的,只要两个项目里的key相同,然后访问同一个域名都带着该cookie即可。

五、单点登录

上面解决了同域名的session问题,但如果taobao.comtianmao.com这种不同的域名也想共享session呢?

去百度了解下:https://www.jianshu.com/p/75edcc05acfd

最终解决方案:都去中央认证器

spring session已经解决不了不同域名的问题了。无法扩大域名

sso思路

记住一个核心思想:建议一个公共的登陆点server,他登录了代表这个集团的产品就登录过了

img

上图是CAS官网上的标准流程,具体流程如下:有两个子系统app1app2

  1. 用户访问app1系统,app1系统是需要登录的,但用户现在没有登录。
  2. 跳转到CAS server,即SSO登录系统,以后图中的CAS Server我们统一叫做SSO系统。 SSO系统也没有登录,弹出用户登录页。
  3. 用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session,浏览器(Browser)中写入SSO域下的Cookie。
  4. SSO系统登录完成后会生成一个STService Ticket),然后跳转到app1系统,同时将ST作为参数传递给app1系统。
  5. app1系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。
  6. 验证通过后,app系统将登录状态写入session并设置app域下的Cookie。

至此,跨域单点登录就完成了。以后我们再访问app系统时,app就是登录的。接下来,我们再看看访问app2系统时的流程。

  1. 用户访问app2系统,app2系统没有登录,跳转到SSO。
  2. 由于SSO已经登录了,不需要重新登录认证。
  3. SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2
  4. app2拿到ST,后台访问SSO,验证ST是否有效。
  5. 验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。

这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。

SSO系统登录后,跳回原业务系统时,带了个参数ST,业务系统还要拿ST再次访问SSO进行验证,觉得这个步骤有点多余。如果想SSO登录认证通过后,通过回调地址将用户信息返回给原业务系统,原业务系统直接设置登录状态,这样流程简单,也完成了登录,不是很好吗?

其实这样问题时很严重的,如果我在SSO没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。

SSO-Single Sign On

  • server:登录服务器、8080 、ssoserver.com
  • web-sample1:项目1 、8081 、client1.com
  • web-sample2:项目1 、8082 、client2.com

3个系统即使域名不一样,想办法给三个系统同步同一个用户的票据;

  • 中央认证服务器
  • 其他系统都去【中央认证服务器】登录,登录成功后跳转回原服务
  • 一个系统登录,都登录;一个系统登出,都登出
  • 全系统统一一个sso-sessionId
  1. 访问服务:SSO客户端发送请求访问应用系统提供的服务资源。

  2. 定向认证:SSO客户端会重定向用户请求到SSO服务器

  3. 用户认证:用户身份认证。

  4. 发放票据:SSO服务器会产生一个随机的Service Ticket

  5. 验证票据:SSO服务器验证票据Service Ticket的合法性,验证通过后,允许客户端访问服务。

  6. 传输用户信息:SSO服务器验证票据通过后,传输用户认证结果信息给客户端。

  7. 单点退出:用户退出单点登录。

开源项目

先看一下开源sso的项目:https://gitee.com/xuxueli0323/xxl-sso

  • ssoserver.com 登录认证服务
  • client1.com
  • cleitn2.com

修改HOSTS:127.0.0.1 ssoserver.com+client1.com+client2.com

  • server:登录服务器、8080 、ssoserver.com
  • web-sample1:项目1 、8081 、client1.com
  • web-sample2:项目1 、8082 、client2.com
# 根项目下
mvn clean package -Dmaven.skip.test=true
# 打包生成了server和client包
# 启动server和client
#server8080  cient1:web-sample8081 cient2:web-sample8082
# 让client12登录一次即可
java -jar server.jar # 8080
java -jar client.jar 
# 启动多个web-sample模拟多个微服务

把core项目mvc install 。启动server

流程
  • 发送8081/employees请求,判断没登录就跳转到server.com:8080/login.html登录页,并带上现url
  • server登录页的时候,有之前带过来的url信息,发送登录请求的时候也把url继续带着
    • doLogin登录成功后返回一个token(保存到server域名下)然后重定向
  • 登录完后重定向到带的url参数的地址。
  • 跳转回业务层的时候,业务层要能感知是登录过的,调回去的时候带个uuid,用uuid去redis里(课上说的是去server里再访问一遍,为了安全性?)看user信息,保存到它系统里自己的session
  • 以后无论哪个系统访问,如果session里没有指定的内容的话,就去server登录,登录过的话已经有了server的cookie,所以不用再登录了。回来的时候就告诉了子系统应该去redis里怎么查你的用户内容

还得得补充一句,老师课上讲得把票据放到controller里太不合适了,你最起码得放到filter或拦截器里

sso解决

client1.com 8081 和 client2.com 8082 都跳转到ssoserver 8080

  • 给登录服务器留下痕迹
  • 登录服务器要将token信息重定向的时候,带到url地址上
  • 其他系统要处理url地址上的token,只要有,将token对应的用户保存到自己的session
  • 自己系统将用户保存在自己的session中
<body>
    <form action="/employee" method="get">
        <input type="text" name="username" value="test">
        <button type="submit">查询</button>
    </form>
</body>
@GetMapping(value = "/employees") // a系统
public String employees(Model model,
                        HttpSession session,
                        @RequestParam(value = "redisKey", required = false) String redisKey) {

    // 有loginToken这个参数,代表去过server端登录过了,server端里在redis里保存了个对象,而key:uuid给你发过来了
    // 有loginToken这个参数的话代表是从登录页跳回来的,而不是系统a直接传过来的
    // 你再拿着uuid再去查一遍user object,返回后设置到当前的系统session里
    // 提个问题:为什么当时不直接返回user对象,而是只返回个uuid?其实也可以,但是参数的地方也得是required = false。可能也有一些安全问题
    if (!StringUtils.isEmpty(redisKey)) { // 这个逻辑应该写到过滤器或拦截器里
        RestTemplate restTemplate=new RestTemplate();
        // 拿着token去服务器,在服务端从redis中查出来他的username
        ResponseEntity<Object> forEntity =
            restTemplate.getForEntity("http://ssoserver.com:8080/userInfo?redisKey="+ redisKey, Object.class);

        Object loginUser = forEntity.getBody();
        // 设置到自己的session中
        session.setAttribute("loginUser", loginUser);
    }
    // session里有就代表登录过 // 获得user
    Object loginUser = session.getAttribute("loginUser");

    if (loginUser == null) { // 又没有loginToken,session里又没有object,去登录页登录
        return "redirect:" + "http://ssoserver.com:8080/login.html"
            + "?url=http://clientA.com/employees";
    } else {// 登录过,执行正常的业务
        List<String> emps = new ArrayList<>();

        emps.add("张三");
        emps.add("李四");
        model.addAttribute("emps", emps);
        return "employees";
    }
}
server端
  • 子系统都先去login.html这个请求,
    • 这个请求会告诉登录过的系统的令牌,
    • 如果没登录过就带着url重新去server端,server给一个登录页,如下
<body>
<form action="/doLogin" method="post">
    <!--刚才要请求数据的url,没有也没关系,就不跳转了呗-->
    <input type="hidden" name="url" th:value="${url}">
    <!--带上当前登录的username-->
<!--    <input type="hidden" name="user" th:value="${username}">-->
        用户名:<input name="username" value="test"><br/>
        密码:<input name="password" type="password" value="test">
    <input type="submit" value="登录">
</form>
</body>

当点击登录之后,server端返回一个cookie,子系统重新返回去重新请去业务。于是又来server端验证,这回server端有cookie了,该cookie里有用户在redis中的key,重定向时把key带到url后面,子系统就知道怎么找用户信息了

@Controller
public class LoginController {

	@Autowired
	private StringRedisTemplate stringRedisTemplate;

	@ResponseBody
	@GetMapping("/userInfo") // 得到redis中的存储过的user信息,返回给子系统的session中
	public Object userInfo(@RequestParam("redisKey") String redisKey){
		// 拿着其他域名转发过来的token去redis里查
		Object loginUser = stringRedisTemplate.opsForValue().get(redisKey);
		return loginUser;
	}


	@GetMapping("/login.html") // 子系统都来这
	public String loginPage(@RequestParam("url") String url,
							Model model,
							@CookieValue(value = "redisKey", required = false) String redisKey) {
		// 非空代表就登录过了
		if (!StringUtils.isEmpty(redisKey)) {
			// 告诉子系统他的redisKey,拿着该token就可以查redis了
			return "redirect:" + url + "?redisKey=" + redisKey;
		}
		model.addAttribute("url", url);

		// 子系统都没登录过才去登录页
		return "login";
	}

	@PostMapping("/doLogin") // server端统一认证
	public String doLogin(@RequestParam("username") String username,
						  @RequestParam("password") String password,
						  HttpServletResponse response,
						  @RequestParam(value="url",required = false) String url){
		// 确认用户后,生成cookie、redis中存储 // if内代表取查完数据库了
		if(!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){//简单认为登录正确
			// 登录成功跳转 跳回之前的页面
			String redisKey = UUID.randomUUID().toString().replace("-", "");
			// 存储cookie, 是在server.com域名下存
			Cookie cookie = new Cookie("redisKey", redisKey);
			response.addCookie(cookie);
			// redis中存储
			stringRedisTemplate.opsForValue().set(redisKey, username+password+"...", 30, TimeUnit.MINUTES);
			// user中存储的url  重定向时候带着token
			return "redirect:" + url + "?redisKey=" + redisKey;
		}
		// 登录失败
		return "login";
	}

}

六、登录拦截器

通用登录拦截器

因为订单系统必然涉及到用户信息,因此进入订单系统的请求必须是已经登录的,所以我们需要通过拦截器对未登录订单请求进行拦截

  • 先注入拦截器HandlerInterceptor组件
  • 在config中实现WebMvcConfigurer接口.addInterceptor()方法
  • 拦截器和认证器的关系我在前面认证模块讲过,可以翻看,这里不赘述了
@Component
public class LoginUserInterceptor implements HandlerInterceptor {

	public static ThreadLocal<MemberRespVo> threadLocal = new ThreadLocal<>();

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

		String uri = request.getRequestURI();
		// 这个请求直接放行
		boolean match = new AntPathMatcher().match("/order/order/status/**", uri);
		if(match){
			return true;
		}
		// 获取session
		HttpSession session = request.getSession();
		// 获取登录用户
		MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
		if(memberRespVo != null){
			threadLocal.set(memberRespVo);
			return true;
		}else{
			// 没登陆就去登录
			session.setAttribute("msg", AuthServerConstant.NOT_LOGIN);
			response.sendRedirect("http://auth.gulimall.com/login.html");
			return false;
		}
	}
}
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**");
    }
}

加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查

在auth-server中登录成功后会把会话设置到session中

MemberRespVo data = login.getData("data",new TypeReference<MemberRespVo>);
session.setAttribute(AuthServerConstant.LOGIN_USER,data);

购物车的登录拦截器

因为购物车允许临时用户,所以自定义购物车拦截器

而登录操作在其他服务页面里完成即可。也可以重定向解决

具体代码去购物车博文里找

笔记不易:

离线笔记均为markdown格式,图片也是云图,10多篇笔记20W字,压缩包仅500k,推荐使用typora阅读。也可以自己导入有道云笔记等软件中

  • 26
    点赞
  • 91
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值