【Project】云商城 - 认证服务

本文将详细介绍云商城的认证服务。云商城完整项目介绍见文章 【Project】云商城

认证服务

认证服务 mall-auth-server,用于实现以下功能:

  • 用户注册
  • 用户登录
  • 社交登录
  • 单点登录

用户注册

前端配置

  1. 导入 Maven 依赖(注意一定要导入依赖,否则不报错也无法显示页面效果)
1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<!-- 版本由 Spring Boot 进行管理-->
</dependency>
  1. 配置关闭缓存
1
2
3
Spring:
thymeleaf:
cache: false # 开发过程建议关闭缓存
  1. resources 目录下存放前端代码:
image-20220113095419390
  1. 前端页面文件必须加上
1
2
3
<!DOCTYPE html>
<!-- 使用 thymeleaf 中必须声明加上该行代码 -->
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  1. 如果前端页面跳转时有固定前缀(例如 /static),则需要在配置文件中指定该前缀:
1
2
3
4
5
spring:
resources:
static-locations: [classpath:/static/]
mvc:
static-path-pattern: /static/** # 因为所有的请求都额外带了前缀 /static/,为了后期动静分离
  1. 配置静态页面跳转 Controller,只有配置了页面跳转规则才可以访问到 templates 目录下的页面。

Spring Boot 只支持自动跳转到 index.html 页面。templates 目录下的其他路径都不能直接在浏览器中访问到,必须通过 Controller 进行跳转

1
2
3
4
5
6
7
8
9
10
11
@GetMapping({"/", "/login.html"})
public String loginPage() {
// 转发到 login.html 视图
return "login";
}

@GetMapping("/reg.html")
public String regPage() {
// 转发到 reg.html 视图
return "reg";
}

如果每一个页面跳转都这样写难免有些复杂,我们可以将页面跳转的逻辑单独抽取到一个 WebMvcConfigurer 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class AuthWebConfig implements WebMvcConfigurer {

/**
* 在这里配置静态资源的跳转规则
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("login");
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}

WebMvcConfigurer 中重写的方法将会覆盖 Spring MVC 中默认的方法,从而做到定制化效果,具体原理见文章【Spring Boot】Spring Boot2 源码分析

注意此时不能标注 @EnableWebMvc 注解,否则将变成全面接管 Spring MVC,需要自己实现所有功能

登录页面渲染效果:

image-20220112215321726

业务流程

用户注册模块需要完成以下功能:

  • 发送短信验证码
  • 数据校验
    • 前端数据校验
    • 后端 JSR 303 校验
    • 验证码校验
  • 远程调用会员服务 mall-member 保存用户数据
    • 校验用户名和手机号是否已存在
    • 用户密码加密存储

总体流程图:

image-20201231084909415

1. 发送短信验证码

本项目使用阿里云的短信服务实现短信验证功能,具体开通与使用方法见文章【AlibabaCloud】阿里云短信服务

在给用户发送短信验证码后,需要将该验证码存储在 Redis 缓存中,保存格式:prefix:phoneNumber : 验证码_currentTime,例如 sms:code:182385494 : s82hd_83627171。其中增加 currentTime 的目的是防止用户在 60 秒内重复点击发送验证码按钮。

短信验证码业务的整体逻辑为:

  • 根据用户的手机号拼接出 key = sms:code:182385494
  • 去 Redis 中查询该 key 对应的 value 值
    • 如果不存在,说明该用户是第一次注册。随机生成一个验证码,并拼接上当前时刻的时间戳s82hd_83627171。将验证码和用户手机号按照格式: sms:code:182385494 : s82hd_83627171 存储到 Redis 中
    • 如果存在,则先根据用户手机号拼接成的 key 去 Redis 中查询 value 值: s82hd_83627171,截取出后面的时间戳和当前时间进行比较,
      • 如果相差小于 60 秒,则阻止用户重复点击
      • 如果大于 60 秒,才可以继续为其生成新的验证码并拼接上当前的时间戳,最终重新保存到 Redis 中

验证码发送流程图:

image-20201231012207446

短信验证码功能的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@ResponseBody
@GetMapping("/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {
// TODO 接口防刷

// 1. 首先检验用户是否在60秒内重复点击发送按钮,
// 检验方式为:去Redis中查询当前手机号对应的value值(为该短信上一次发送的时间),如果该时间与当前相比不大于60秒,则拒绝发送
String redisCode = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
if (!StringUtils.isEmpty(redisCode)) {
long time = Long.parseLong(redisCode.split("_")[1]);
if (System.currentTimeMillis() - time < 60 * 1000) {
// 如果发送间隔小于60s,不允许再发送
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}

// 2. 当可以再次发送时,随机生成验证码,并将该验证码存储在 Redis 中
String code = UUID.randomUUID().toString().substring(0, 5);
// 向 Redis 中存储验证码时,需要带上当前时间(为了保证60秒内不能重复发送短信)
String codeWithTime = code + "_" + System.currentTimeMillis();
// 3. 将用户的手机号和验证码存到缓存中。格式:prefix:phoneNumber-currentTime - 验证码
// 例如 sms:code:1829847-83627171 - s82hd
// 新的验证码会覆盖旧的。给每个验证码设置过期时间为十分钟
redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone,
codeWithTime, 10, TimeUnit.MINUTES);
// 4. 远程调用第三方服务,向用户手机发送验证码
thirdPartFeignService.sendCode(phone, code);
return R.ok();
}

2. 数据校验

分别进行前端数据校验和后端数据校验,限制用户名长度、密码长度与类型、手机号格式。关于后端 JSR 303 校验的详细介绍参考文章 【Java】JSR 303 数据校验

  1. 在实体类上添加注解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class UserRegisterVo {
@NotEmpty(message = "用户名不能为空")
@Length(min=6,max=19,message = "用户名长度在6—18字符")
private String userName;

@NotEmpty(message = "密码必须填写")
@Length(min=6,max=19,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;
}
  1. 在 Controller 层添加验证的异常处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@PostMapping("/register")
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中(RedirectAttributes里的数据将保存到session中,这样重定向也能获取到数据。如果放到请求域中,则重定向后的页面将无法获得数据
attributes.addFlashAttribute("errors", errors);
});
// 1.2 重定向到注册页
return "redirect:http://auth.yunmall.com:20000/reg.html";
}else {
// 2.若JSR303校验通过
// 后续业务....
}
}
  1. 验证 Reids 中的验证码是否等于用户传来的验证码

用户注册业务的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 点击注册按钮,先校验前端传来的数据是否合法
* 若合法则验证短信验证码是否匹配,如果也合法,则使用重定向转发到首页
* 选择使用重定向是为了防止用户多次刷新页面时重复提交
* @param registerVo
* @param result
* @param attributes
* @return
*/
@PostMapping("/register")
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.yunmall.com:20000/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.yunmall.com:20000/login.html";
} else {
// 调用失败,返回注册页并显示错误信息
String msg = (String) r.get("msg");
errors.put("msg", msg);
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.yunmall.com:20000/reg.html";
}
} else {
// 2.2 验证码错误
errors.put("code", "验证码错误");
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.yunmall.com:20000/reg.html";
}
}
}

3. 远程调用会员服务保存数据

当验证通过后,需要远程调用会员服务 mall-member 保存数据。

  1. Controller 层需要捕获可能出现的异常:PhoneExistExceptionUsernameExistException
1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping("/register")
public R register(@RequestBody MemberRegisterVo vo) {
try {
// 捕获可能发生的异常:用户名已存在或手机号已存在
memberService.register(vo);
} catch (PhoneExistException e) {
return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(), BizCodeEnum.PHONE_EXIST_EXCEPTION.getMsg());
} catch (UsernameExistException e) {
return R.error(BizCodeEnum.USER_EXIST_EXCEPTION.getCode(), BizCodeEnum.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
  1. Service 层负责将认证服务传来的 vo 数据进行解析,然后检验用户名和手机号是否已存在,并最终保存到数据库中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Override
public void register(MemberRegisterVo vo) {
// 检查电话号是否唯一,若不唯一则抛出异常
checkPhoneUnique(vo.getPhone());
// 检查用户名是否唯一,若不唯一则抛出异常
checkUsernameUnique(vo.getUserName());

MemberEntity memberEntity = new MemberEntity();

// 先去会员等级表查询默认的等级名称是什么
Long levelId = memberLevelDao.getDefaultLevel();
// 设置默认等级
memberEntity.setLevelId(levelId);

// 检查用户名和手机号是否唯一
// 为了让controller能感知异常,使用异常机制
memberEntity.setMobile(vo.getPhone());
memberEntity.setUsername(vo.getUserName());

// 密码要进行加密存储
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());
// 设置密码
memberEntity.setPassword(encode);

this.baseMapper.insert(memberEntity);
}

/**
* 声明可能抛出的异常PhoneExistException
* @param phone
* @throws PhoneExistException
*/
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException {
Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (count > 0) {
throw new PhoneExistException();
}
}

/**
* 声明可能抛出的异常UsernameExistException
* @param username
* @throws UsernameExistException
*/
@Override
public void checkUsernameUnique(String username) throws UsernameExistException {
Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("username", username));
if (count > 0) {
throw new UsernameExistException();
}
}

若已存在,则需要抛出自定义的异常到 Controller 层,然后返回相应的报错信息给认证服务,最后渲染到前端页面上。

用户密码加密存储

MD5

https://www.jianshu.com/p/81c30781d4f7

MD5 的全称是 MD5 信息摘要算法(英文:MD5 Message-Digest Algorithm ),一种被广泛使用的密码散列函数(一种常用的哈希算法),可以产生一个128位(16字节,1字节8位)的散列值(常见的是用32位的16进制表示,比如:0caa3b23b8da53f9e4e041d95dc8fa2c),用于确保信息传输的完整一致。MD5 的特性:

  • 不可逆:没有系统有办法知道 MD5 原来的文字是什么,除非暴力尝试
  • 高度的离散性:MD5 码具有高度的散列性,没有规律可循,哪怕原信息只有一点点的变化,比如多个空格,那么就会导致 MD5 发生巨大变化,也可以说产生的 MD5 码是不可预测的
  • 压缩性:任意长度的数据,算出的 MD5 值的长度都是固定的
  • 弱碰撞性:已知原数据和其 MD5 的值,想找到一个具有相同 MD5 值得数据(即伪造数据)是非常困难的。

但 MD5 较容易破解,因为其可以通过暴力尝试的方法将很多常见字符串的 MD5 值匹配出来存到一个字典中,因此不够安全。

一种更加安全的加密方法是在 MD5 的基础上加盐。具体做法是,在要加密的字符串上随机拼接一个字符串,然后再进行编码。这样生成的加密值就不能再用建立好的字典匹配出来了。一种可行的思路是将盐值也存储到数据库中,每次匹配时将盐值读取出来后进行拼接然后再编码。这样做的缺点是还需要额外存储盐值,效率上低了一些。

BCryptPasswordEncoder

为防止数据库被爆破后用户密码被人破解,本项目使用 Spring Security 提供的 BCryptPasswordEncoder 编码器(基于 BCrypt 算法)对前端传来的用户密码进行加密(非普通的加盐加密方式)。该编码器的特点是:

  • 同一个密码,调用多次该编码器得到的结果不相等,就这导致明文和密文没有一一对应关系,无法暴力匹配破解
  • 编码后的字符串中包含了盐值,例如 $10$O8mw.X0151vZoan1oVOi.OKcAtTfeoSHsSLBMIUNUQiMu3Tf9iB/G$$包裹的部分为其增加的盐值,这样就不需要额外存储盐值到数据库中了
  • 调用其 matches() 方法即可返回明文与加密值是否匹配

使用该编码器就可以保证,前端用户传来的密码经过该编码器的 matches() 方法计算即可得知该明文密码是否和数据库中之前存储的密码相匹配。

使用方法:

1
2
3
4
5
6
// 加密。vo.getPassword() 为前端传来的明文密码
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());

// 解密。passwordDB 为数据库中存储的加密后密码
boolean matches = passwordEncoder.matches(password, passwordDB);

用户登录

image-20220113125330410

业务流程

用户登录模块的业务流程:

  • 前端传递用户名和密码数据到认证服务
  • 认证服务远程调用会员服务
  • 会员服务验证用户名和密码是否匹配
  • 返回验证结果给认证服务

总体流程图:

image-20201231012134722

认证服务

如果验证成功,跳转到首页,并且在 Session 中保存用户信息 vo(首页将进行展示);否则还是跳转到登录页面,并且提示错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequestMapping("/login")
public String login(UserLoginVo vo, RedirectAttributes attributes, HttpSession session) {
R r = memberFeignService.login(vo);
if (r.getCode() == 0) {
// 接收会员服务查询到的用户完整信息
String jsonString = JSON.toJSONString(r.get("memberEntity"));
MemberResponseVo memberResponseVo = JSON.parseObject(jsonString, new TypeReference<MemberResponseVo>() {
});
// 保存到 Session 中,并重定向到商城首页。key = loginUser; value = vo 对象
session.setAttribute(AuthServerConstant.LOGIN_USER, memberResponseVo);
return "redirect:http://yunmall.com:10000/";
} 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.yunmall.com:20000/login.html";
}
}

会员服务

会员服务需要判断认证服务传来的用户名和密码是否匹配,如果匹配就将用户数据返回给认证服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 根据传来的数据验证用户名和密码是否匹配
* @param vo
* @return
*/
@RequestMapping("/login")
public R login(@RequestBody MemberLoginVo loginVo) {
MemberEntity entity = memberService.login(loginVo);
if (entity != null) {
return R.ok().put("memberEntity", entity);
} else {
return R.error(BizCodeEnum.LOGIN_INVALID_EXCEPTION.getCode(), BizCodeEnum.LOGIN_INVALID_EXCEPTION.getMsg());
}
}

Service 层验证用户名和密码是否匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 获取 vo 的用户名和密码,去数据库中查看是否匹配
* @param vo 前端传来的数据
* @return 返回 null 代表不存在
*/
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginAccount = vo.getLoginAccount();
String password = vo.getPassword();

MemberEntity entity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>()
.eq("username", loginAccount)
.or().eq("mobile", loginAccount));
if (entity == null) {
// 查询不到,登录失败
return null;
} else {
// 如果能查到,还要和密码匹配看是否一样
String passwordDB = entity.getPassword();
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
boolean matches = encoder.matches(password, passwordDB);
if (matches) {
return entity;
} else {
return null;
}
}
}

社交登录

QQ、微博,Gitee 等网站的用户量非常大,我们自己开发的网站为了简化网站的登陆和注册逻辑,可以引入社交登录功能,允许其他平台的用户快速注册并登陆自己的网站。

OAuth 2.0

详细介绍:https://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html

OAuth 2.0(Open Authorization 2.0,开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方应用或分享他们数据的所有内容。它并不是一个协议,而是一种解决方案

image-20220202214522530

本节以 Gitee 的授权认证为例介绍社交登录的配置方法。完整的开通与配置参考博客 https://blog.csdn.net/qq_40065776/article/details/105255146 。 OAuth 2.0 的流程图:

img

整体流程:

  • (A) 前端页面引导用户到 Gitee 的授权页地址,并发送本系统的应用 ID 与后端服务器回调地址 /oauth2.0/weibo/success 给授权页,方便其授权后回调到后端服务器
  • (B) 用户在 Gitee 的授权页输入正确的信息
  • (C) Gitee 认证服务器验证用户输入的信息。如果正确,则生成一个一次性的授权码 Code,并将该授权码回调发给前端发来的回调地址(该地址指向后端应用服务器,而非回到前端页面,这样浏览器中就不会暴露用户的授权码):/oauth2.0/weibo/success
  • (D) 后端应用服务器获取到传来的授权码后,去 Gitee 认证服务器换取 Access Token(访问令牌),换取后授权码就失效了
  • (E) Gitee 认证服务器发送 Access Token 给后端应用服务器
  • 后端服务器获取到 Access Token 后,就可以向 Gitee 认证服务器发送请求访问其公开的社交信息了(例如昵称、头像等)

其中一些细节:

  • 使用授权码 Code 换取 Access Token 后,Code 就立即失效。
  • 同一个用户的 Access Token 在过期时间内可以重复使用。
  • (A) 步骤在跳转到授权页时,如果之前已经登陆了 Gitee,则 Gitee 网站的 Cookie 里就保存了之前登陆过的用户信息,此时就不会再显示授权页了,而是 Gitee 认证服务器直接发送新的授权码给回调地址/oauth2.0/weibo/success。也就是直接到步骤 (D) 了 。除非清空 Cookie 或退出登录

以微博登录为例,流程图:

image-20201231084733753

时序图:

image-20220113154148949

回调处理代码

以微博社交登录为例,回调处理的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@RequestMapping("/oauth2.0/weibo/success")
public String authorize(String code, HttpSession session) throws Exception {
// 1. 使用code换取token,换取成功则继续2,否则重定向至登录页
Map<String, String> query = new HashMap<>();
query.put("client_id", "2144471074");
query.put("client_secret", "ff63a0d8d591a85a29a19492817316ab");
query.put("grant_type", "authorization_code");
query.put("redirect_uri", "http://auth.yunmall.com/oauth2.0/weibo/success");
query.put("code", code);
// 发送post请求换取access token
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", new HashMap<String, String>(), query, new HashMap<String, String>());

Map<String, String> errors = new HashMap<>();
if (response.getStatusLine().getStatusCode() == 200) {
// 2. 调用会员服务远程接口进行oauth登录,登录成功则转发至首页并携带返回用户信息,否则转发至登录页
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, new TypeReference<SocialUser>() {
});
R login = memberFeignService.login(socialUser);
// 2.1 远程调用成功,返回首页并携带用户信息
if (login.getCode() == 0) {
String jsonString = JSON.toJSONString(login.get("memberEntity"));
System.out.println("----------------" + jsonString);
MemberResponseVo memberResponseVo = JSON.parseObject(jsonString, new TypeReference<MemberResponseVo>() {
});
System.out.println("----------------" + memberResponseVo);
// 将 POJO 直接存储到 Session 中,开启 Spring Session,
// 其会使用我们自定义的 JSON 序列化器将该对象转换成 JSON 字符串后保存到 Redis 中,
// 从而实现多个微服务间的 Session 共享
session.setAttribute("loginUser", memberResponseVo);
// 重定向到父域
return "redirect:http://yunmall.com";
}else {
// 2.2 否则返回登录页
errors.put("msg", "登录失败,请重试");
session.setAttribute("errors", errors);
return "redirect:http://auth.yunmall.com/login.html";
}
}else {
errors.put("msg", "获得第三方授权失败,请重试");
session.setAttribute("errors", errors);
return "redirect:http://auth.yunmall.com/login.html";
}
}

微博认证服务器返回的数据被封装到 SocialUser 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 用以封装社交登录认证后换回的令牌等信息
*/
@Data
public class SocialUser {
/**
* 令牌
*/
private String access_token;

private String remind_in;

/**
* 令牌过期时间
*/
private long expires_in;

/**
* 该社交用户的唯一标识
*/
private String uid;

private String isRealName;
}

其中,uid 是微博认证服务器为每个用户指定的唯一识别。在成功获取到认证服务器返回的数据后,将远程调用会员服务 mall-member,将该用户信息保存到会员数据库中。

新社交用户绑定

在会员服务中,需要判断传来的社交用户 SocialUser 是否是第一次使用该账号登录:

  • 如果是,就需要进行注册流程:将该社交账号与本系统的某一个会员账号信息进行绑定关联
  • 如果不是,则不需要额外注册,只需要更新该用户的 Access Token 和其过期时间即可

将社交账号与本系统会员账号进行绑定关联的方式是:在本系统的数据库中为每一个会员账号添加一个 uid 字段(一个社交账号对应唯一一个 uid),当新登录一个社交账号后,就判断该账号的 uid 是否和数据库中某个会员账号的 uid 相同:

  • 如果相同,则说明该社交用户之前已经登录过,已经和本系统的某个会员账号进行了绑定。此时只需要更新该用户的 Access Token 和其过期时间即可
  • 如果不同,则说明该社交用户是第一次登录,此时需要为其注册,即创建一个会员账号,设置其 uid 等于该社交账号的 uid,然后将社交账号的昵称头像等信息设置到本系统的会员账号内,从而完成了社交账号与本地会员账号的绑定与注册

其中,保存 Access Token 的目的是做免登陆/验证。只要用户在微博的认证界面成功登陆过一次,就可以重复使用该 Access Token。下一次再登录本系统,直接使用之前获取的 Access Token 获取 Gitee 认证服务器的数据信息,就可以实现免登陆;也可以在后期获取用户的其他数据。

  1. Controller 层:
1
2
3
4
5
6
7
8
9
@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();
}
}
  1. Service 层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Override
public MemberEntity login(SocialUser socialUser){
// 去数据库查询该 uid 是否存在
MemberEntity uid = this.getOne(new QueryWrapper<MemberEntity>().eq("uid", socialUser.getUid()));
// 判断会员数据库中是否已存在该 uid
if (uid == null) {
// 1. 如果之前未登陆过,则查询其社交信息进行注册
Map<String, String> query = new HashMap<>();
query.put("access_token",socialUser.getAccess_token());
query.put("uid", socialUser.getUid());
// 调用微博api接口获取用户信息
String json = null;
try {
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), query);
json = EntityUtils.toString(response.getEntity());
} catch (Exception e) {
e.printStackTrace();
}
JSONObject jsonObject = JSON.parseObject(json);
// 获得昵称,性别,头像
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
String profile_image_url = jsonObject.getString("profile_image_url");
// 封装用户信息并保存
uid = new MemberEntity();
MemberLevelEntity defaultLevel = memberLevelService.getOne(new QueryWrapper<MemberLevelEntity>().eq("default_status", 1));
uid.setLevelId(defaultLevel.getId());
uid.setNickname(name);
uid.setGender("m".equals(gender)?0:1);
uid.setHeader(profile_image_url);
uid.setAccessToken(socialUser.getAccess_token());
uid.setUid(socialUser.getUid());
uid.setExpiresIn(socialUser.getExpires_in());
this.save(uid);
} else {
// 2. 如果已经存在,说明之前登陆过,则更新令牌与过期时间信息并返回
uid.setAccessToken(socialUser.getAccess_token());
uid.setUid(socialUser.getUid());
uid.setExpiresIn(socialUser.getExpires_in());
this.updateById(uid);
}
return uid;
}

Session 共享问题

在登录成功后,将重定向到商城首页。为了在商城首页显示用户名信息,则需要在 Session 中存储用户的信息。但此时出现了一个问题:登录页面所在认证服务 mall-auth-server 和商品服务 mall-product 内的 Session 数据无法共享,商品服务无法获取到认证服务保存在 Session 中的用户信息。

此时就需要解决 Session 共享问题。具体解决方案见文章【Spring】Spring Session。开启 Spring Session 后就可以直接将要传递给其他微服务的数据存储到 Session 中了。

加签和验签

为保证 Cookie 的安全性。需要对 Session ID 进行加签验签

  • 加签:服务器在发送 Cookie 之前对这个含有 Session ID 的 Cookie 进行签名,生成一个签名 sign 一同发送给浏览器,也作为该域名下的一个 Cookie。
  • 验签:当浏览器再发送请求时,服务器会先验证该请求携带的 Cookie 中,签名 sign 能否匹配上 Session ID。如果能匹配,则认证成功;否则,认证失败,拒绝访问。

支付宝的支付服务同样有加签和验签步骤保证数据安全性。

加签验签的细节:使用某种加密算法(例如 HMAC 算法)对 Session ID 进行加密(需要保存一份私钥在服务端):

1
sign = HMACSHA256(sessionId, secret)

得到的签名 sign 即可发送给客户端。以后客户端携带 Session ID 和 sign 一起访问服务端,服务端只需要再次计算 HMACSHA256(sessionId, secret) 并对比该值和前端传来的 sign 值是否一致,如果一致则说明数据正确无误。

单点登录

如果是同一个父域名下的子域名之间的免登陆也可以称为单点登录

单点登录(Single Sign On,SSO),即一处登录处处登录。SSO 仅仅是一种架构,一种设计,而 CAS 则是实现 SSO 的一种手段。两者是抽象与具体的关系。当然,除了 CAS 之外,实现 SSO 还有其他手段,比如简单的 Cookie。

CAS (Central Authentication Service)中心授权服务,本身是一个开源协议,分为 1.0 版本和 2.0 版本。1.0 称为基础模式,2.0称为代理模式,适用于存在非 Web 应用之间的单点登录。

现在考虑另一种情景:如果同一个公司的不同子网站之间想要实现一处登录处处登录,此时因为域名完全不同,导致上述基于 Spring Session 的单点登录方案失效。

解决方案:在访问每个子网站时,都先判断自己服务端的 Session 中是否已经有用户信息。如果有代表之前登陆过,不需要用户再点击登录了;否则用户在点击登录时,将先重定向到整个系统唯一的一个认证中心服务(例如 sso-server.com)。用户需要在该服务进行登录认证,如果认证通过:

  • 保存该用户的信息到 Redis 中,并为该用户生成一个 token(子网站 client1.com 之后将拿着该 token 去认证中心获取该用户的具体信息)
  • 为该用户生成一个 Cookie 返回给客户端保存(其域名为 sso-server.com)。该 Cookie 并非指向各个子网站,而是指向认证中心sso_token=uuid。保存该 Cookie 的目的是,之后无论是哪个子网站想登陆时都会先重定向到认证中心服务,那么也就一定会携带着这个 Cookie,从而在认证中心就不需要再登陆了,可以直接返回该用户对应的 token
  • 访问重定向前的回调地址(子网站 client1.com)并带上上述提到的 token 以及 Cookie(不带用户信息)。
  • 携带着 token 访问 client1.com 时,就会使用该 token 向 sso-server.com 查询该用户的详细信息 UserVo,并保存到 client1.com 服务自己独有的 Session 当中。
  • 最后转发到前端页面,前端发现 Session 中已经有了 UserVo 数据,就可以直接取出显示了

当其他子网站 client2.com 登录前,同样会执行上述过程,但是此时浏览器已经有了 Cookie,从而就不需要在认证中心服务进行登录了,其可以直接查询到该用户的 token 返回给 client2.com。这样 client2.com 服务就可以使用该 token 查询出用户详细数据 UserVo 并保存到自己的 Session 中,这样自己的子系统之后就不需要再重定向到认证中心服务了。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (session not fund) {
if (token != null) {
// 如果 token 不为空,再去认证中心服务查询真正数据 UserVo,
// 并保存到自己的 Session 中
session.addAttrbutes("loginUser", user);
return "index"
}
// token 为空,则需要重定向到认证中心服务
redirect:/server?url=自己的回调地址
} else {
// 有数据就直接去渲染
return "index"
}

单点登录的本质在于所有业务系统都共同访问了同一个认证中心服务,这个服务返回一个可以公共利用的 Cookie(指向认证中心服务)给浏览器保存,并且返回 token 给各个子网站,子网站使用 token 去认证中心查询到真正的用户数据 UserVo

流程图:

单点登录流程