本文将详细介绍云商城的认证服务。云商城完整项目介绍见文章 【Project】云商城
认证服务
认证服务 mall-auth-server
,用于实现以下功能:
用户注册
前端配置
- 导入 Maven 依赖(注意一定要导入依赖,否则不报错也无法显示页面效果)
1 2 3 4 5
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
|
- 配置关闭缓存
1 2 3
| Spring: thymeleaf: cache: false
|
- 在
resources
目录下存放前端代码:
- 前端页面文件必须加上
1 2 3
| <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
- 如果前端页面跳转时有固定前缀(例如
/static
),则需要在配置文件中指定该前缀:
1 2 3 4 5
| spring: resources: static-locations: [classpath:/static/] mvc: static-path-pattern: /static/**
|
- 配置静态页面跳转 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() { return "login"; }
@GetMapping("/reg.html") public String regPage() { return "reg"; }
|
如果每一个页面跳转都这样写难免有些复杂,我们可以将页面跳转的逻辑单独抽取到一个 WebMvcConfigurer
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Configuration public class AuthWebConfig implements WebMvcConfigurer {
@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,需要自己实现所有功能
登录页面渲染效果:
业务流程
用户注册模块需要完成以下功能:
- 发送短信验证码
- 数据校验
- 前端数据校验
- 后端 JSR 303 校验
- 验证码校验
- 远程调用会员服务
mall-member
保存用户数据
总体流程图:
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 中
验证码发送流程图:
短信验证码功能的完整代码:
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) {
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) { return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg()); } }
String code = UUID.randomUUID().toString().substring(0, 5); String codeWithTime = code + "_" + System.currentTimeMillis(); redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, codeWithTime, 10, TimeUnit.MINUTES); thirdPartFeignService.sendCode(phone, code); return R.ok(); }
|
2. 数据校验
分别进行前端数据校验和后端数据校验,限制用户名长度、密码长度与类型、手机号格式。关于后端 JSR 303 校验的详细介绍参考文章 【Java】JSR 303 数据校验
- 在实体类上添加注解:
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; }
|
- 在 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) { Map<String, String> errors = new HashMap<>(); if (result.hasErrors()){ result.getFieldErrors().forEach(item->{ errors.put(item.getField(), item.getDefaultMessage()); attributes.addFlashAttribute("errors", errors); }); return "redirect:http://auth.yunmall.com:20000/reg.html"; }else { } }
|
- 验证 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
|
@PostMapping("/register") public String register(@Valid UserRegisterVo registerVo, BindingResult result, RedirectAttributes attributes) { Map<String, String> errors = new HashMap<>(); if (result.hasErrors()){ result.getFieldErrors().forEach(item->{ errors.put(item.getField(), item.getDefaultMessage()); attributes.addFlashAttribute("errors", errors); }); return "redirect:http://auth.yunmall.com:20000/reg.html"; } else { String code = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone()); if (!StringUtils.isEmpty(code) && registerVo.getCode().equals(code.split("_")[0])) { redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone()); 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 { errors.put("code", "验证码错误"); attributes.addFlashAttribute("errors", errors); return "redirect:http://auth.yunmall.com:20000/reg.html"; } } }
|
3. 远程调用会员服务保存数据
当验证通过后,需要远程调用会员服务 mall-member
保存数据。
- Controller 层需要捕获可能出现的异常:
PhoneExistException
和 UsernameExistException
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(); }
|
- 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);
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); }
@Override public void checkPhoneUnique(String phone) throws PhoneExistException { Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone)); if (count > 0) { throw new PhoneExistException(); } }
@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
| BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); String encode = passwordEncoder.encode(vo.getPassword());
boolean matches = passwordEncoder.matches(password, passwordDB);
|
用户登录
业务流程
用户登录模块的业务流程:
- 前端传递用户名和密码数据到认证服务
- 认证服务远程调用会员服务
- 会员服务验证用户名和密码是否匹配
- 返回验证结果给认证服务
总体流程图:
认证服务
如果验证成功,跳转到首页,并且在 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.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
|
@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
|
@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,开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方应用或分享他们数据的所有内容。它并不是一个协议,而是一种解决方案。
本节以 Gitee 的授权认证为例介绍社交登录的配置方法。完整的开通与配置参考博客 https://blog.csdn.net/qq_40065776/article/details/105255146 。 OAuth 2.0 的流程图:
整体流程:
(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 或退出登录
以微博登录为例,流程图:
时序图:
回调处理代码
以微博社交登录为例,回调处理的代码:
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 { 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); 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) { String json = EntityUtils.toString(response.getEntity()); SocialUser socialUser = JSON.parseObject(json, new TypeReference<SocialUser>() { }); R login = memberFeignService.login(socialUser); 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); session.setAttribute("loginUser", memberResponseVo); return "redirect:http://yunmall.com"; }else { 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 认证服务器的数据信息,就可以实现免登陆;也可以在后期获取用户的其他数据。
- 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(); } }
|
- 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){ MemberEntity uid = this.getOne(new QueryWrapper<MemberEntity>().eq("uid", socialUser.getUid())); if (uid == null) { Map<String, String> query = new HashMap<>(); query.put("access_token",socialUser.getAccess_token()); query.put("uid", socialUser.getUid()); 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 { 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) { session.addAttrbutes("loginUser", user); return "index" } redirect:/server?url=自己的回调地址 } else { return "index" }
|
单点登录的本质在于所有业务系统都共同访问了同一个认证中心服务,这个服务返回一个可以公共利用的 Cookie(指向认证中心服务)给浏览器保存,并且返回 token 给各个子网站,子网站使用 token 去认证中心查询到真正的用户数据 UserVo
。
流程图: