文章目录
前言
本篇主要介绍谷粒商城项目认证服务的三种模式(账号密码登录,社交登录,单点登录),以及整合第三方短信验证码,引入Spring Session
解决分布式Session共享问题。
其中账号密码登录,本篇中只介绍如何整合第三方短信验证,以及使用Redis进行验证码校验,防止验证码重复提交,并且不再重复介绍如何搭建认证服务
,以及Nginx,网关配置。
对应视频P211-P235
一、账号密码登录
1、整合短信验证
在本项目中,选择使用阿里云提供的短信服务:
我选择整合的是三网短信接口
@Data
@Component
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")//和配置文件绑定,读取配置文件中键值对应的信息
public class SmsComponent {
private String host;
private String path;
private String method;
private String appcode;
public void sendSmsMessage(String phone, String code) {
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);
bodys.put("template_id", "CST_ptdie100"); //注意,CST_ptdie100该模板ID仅为调试使用,调试结果为"status": "OK" ,即表示接口调用成功,然后联系客服报备自己的专属签名模板ID,以保证短信稳定下发
bodys.put("phone_number", phone);
try {
/**
* 重要提示如下:
* HttpUtils请从
* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java
* 下载
*
* 相应的依赖请参照
* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml
*/
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();
}
}
}
配置文件相关内容(我放在了Nacos上):
其中appCode需要自己获取
2、验证码校验&防止重复获取验证
上述操作可以通过redis实现:
- 页面上点击发送短信验证,存入redis,key为前缀拼上手机号,value为验证码,并且给60s的过期时间
(防止同一手机号刷新页面后反复点击发送验证码)
- 点击注册时,从redis中取出验证码,与页面上传入的进行校验。
- 验证码正确,则删除key对应的验证码。
/**
* 发送验证码
*
* @param phone 手机号
* @param code 验证码
* @return 发送结果
*/
@Override
public R sendMessageCode(String phone, String code) {
//缓存key:"message:"拼接上各自的手机号
String result = stringRedisTemplate.opsForValue().get(cacheKey + phone);
if (StringUtils.isNotBlank(result)) {
return R.error("请勿重复发送!");
}
//发送短信
R r = feignClient.sendMessageCode(phone, code);
//发送成功才需要向缓存中存放手机号和对应的验证码
if (r.get("msg").equals("success")) {
stringRedisTemplate.opsForValue().set(cacheKey+phone, JSON.toJSONString(code),60, TimeUnit.SECONDS);
}
//注意,后续手机号验证码校验完成后也需要删除缓存中对应的
return r;
}
/**
* 校验验证码信息
*
* @param vo 前端传递
* @return 校验结果
*/
@Override
public R checkCode(UserRegisterVo vo) {
String phone = vo.getPhone();
String code = vo.getCode();
String result = stringRedisTemplate.opsForValue().get(cacheKey + phone);
String codeRedis = JSON.parseObject(result, String.class);
if (StringUtils.isBlank(codeRedis)) {
return R.error(Result.ERROR,"验证码不存在!");
}
if (!code.equals(codeRedis)) {
return R.error(Result.ERROR,"验证码错误!");
}else {
//删除缓存中的验证码,返回提示
stringRedisTemplate.delete(cacheKey + phone);
return R.ok();
}
}
二、社交登录
什么是社交登录?简单来说,是账号密码登录模式的扩展。平时上网时登录论坛,博客,微博,它们都有自己的账号密码体系,也就是说需要注册对应平台的账号密码。但是这些平台也提供了其他登录方式,可以是QQ,微信登录,这种不需要本平台的账号密码,使用第三方登录的方式就称为社交登录
。
如果选择在这些平台注册用户,那么注册时填报的所有信息都会被平台所保存持有,但是选择社交登录,平台只能保存第三方愿意提供的信息。这样的模式称之为oauth协议
。在项目中用到了oauth2.0的授权码模式
,假设第三方选择的是QQ登录,大致的流程如下:
- 选择第三方方式登录时,平台首先将用户引导到QQ的授权登录页面。
- 用户在QQ的登录页面输入自己的QQ号和密码/扫码。
- QQ服务器会发给平台后端服务器一个code。
- 平台后端服务器利用这个code去请求QQ服务器,拿到
授权码
。 - 平台后端服务器再调用QQ服务器的api,带着
授权码
去获取用户在QQ平台可以公开的信息。
1、第三方平台设置
在本项目中,我选择使用gitee
作为第三方登录平台:Gitee
登录后选择自己的头像,点击账号设置
,在左边的菜单中选择数据管理-第三方应用
,然后点击创建应用
,设置自己的应用主页
和应用回调地址
2、代码整合
首先创建一个配置类,注册到Spring中,作用是和配置文件中的键值进行绑定。
@ConfigurationProperties(prefix = "gulimall.oauth")
@Component
@Data
public class GulimallOAuthConfig {
private String clientId;//Gitee个人中心的Client ID
private String clientSecret;//Gitee个人中心的Client Secret
private String redirectUri;//Gitee设置的应用回调地址
}
处理社交登录请求关键代码:
/**
* 处理社交登录请求
*/
@Controller
@Slf4j
public class OAuth2Controller {
@Autowired
private GulimallOAuthConfig authConfig;
@Autowired
private MemberRemoteFeignClient memberRemoteFeignClient;
@GetMapping("/oauth2.0/gitee/success")
public String auth2Login(@RequestParam("code") String code, HttpSession session) throws Exception {
//code是前端传入的
Map<String, String> map = new HashMap<>();
map.put("grant_type", "authorization_code");
map.put("code", code);
map.put("client_id", authConfig.getClientId());
map.put("redirect_uri", authConfig.getRedirectUri());
map.put("client_secret", authConfig.getClientSecret());
//应用服务器 或 Webview 使用 access_token API 向 码云认证服务器发送post请求传入 用户授权码 以及 回调地址( POST请求 )
//根据code拿到授权码(access_token)
HttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", new HashMap<>(), map, new HashMap<>());
R login = null;
//得到access_token
if (response.getStatusLine().getStatusCode() == 200) {
String s = EntityUtils.toString(response.getEntity());
// System.out.println(s);
SocialUser socialUser = JSON.parseObject(s, SocialUser.class);
//远程调用member服务,进行社交登录逻辑(根据授权码获取用户信息)
login = memberRemoteFeignClient.login(socialUser);
int loginCode = (int) login.get("code");
if (loginCode != 0) {
//登录失败
return "redirect:http://auth.gulimall.com/login.html";
}
} else {
//登录失败
return "redirect:http://auth.gulimall.com/login.html";
}
//整合springSession解决session共享问题
//session共享问题:域名不同,或者同一域名但是多个服务器
LinkedHashMap data = (LinkedHashMap) login.get("data");
ObjectMapper objectMapper = new ObjectMapper();
MemberRespVO memberRespVO = objectMapper.convertValue(data, MemberRespVO.class);
log.info("登录成功:{}",memberRespVO.toString());
session.setAttribute("loginUser",memberRespVO);
return "redirect:http://gulimall.com";
}
}
在页面上右键-审查元素,找到对点击Gitee后跳转的第三方页面:
进行修改,链接如何设置参照文档Gitee OauthApi
<a href="https://gitee.com/oauth/authorize?client_id=填你自己的id&redirect_uri=填你自己的回调地址&response_type=code">
<img style="width: 50px;height: 18px;" src="https://gitee.com/static/images/logo-black.svg?t=158106664" />
<span>Gitee</span>
</a>
3、整体效果演示
首先退出Gitee,在点击谷粒商城登录页面的Gitee后,跳转到Gitee的授权登录页面,地址就是上一步中修改的
点击登录,会发送一个重定向的请求,并且带上code
,通过上一步Controller代码的 @GetMapping("/oauth2.0/gitee/success")
路径映射进行接收。
在我方系统的后台代码中,用code
去调用Gitee的Api,获取到授权码
:
HttpResponse response = HttpUtils.doPost(“https://gitee.com”, “/oauth/token”, “post”, new HashMap<>(), map, new HashMap<>());
然后远程调用Member模块的社交登录方法,用授权码
去获取Gitee提供的信息(这里只贴和实现oauth有关的关键代码),也就是说,Gitee提供哪些信息,我们就向数据库保存这些信息。
/**
* 社交登录
*
* @param dto 第三方登录返回信息类
* @return 成功则返回登录实体类
*/
@Override
public R login(SocialUser dto) {
try {
......
Map<String, String> map = new HashMap<>();
map.put("access_token", dto.getAccessToken());
//获取用户信息
HttpResponse response = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<String, String>(), map);
......
}
最后登录成功,向Session中保存信息,并且跳转回首页。
4、补充:其他模式
oauth2.0除了项目中运用的授权码
模式,还有简化模式
、密码模式
、 客户端凭证模式
,下面简单介绍一个每个模式的执行流程:
简化模式:
- 用户请求授权:用户在客户端中发起请求,重定向到授权服务器。
- 用户登录并授权:用户在授权服务器登录,并同意授予客户端访问权限。
- 获取访问令牌:授权服务器将用户重定向回客户端,返回包含访问令牌的URL片段。
- 使用访问令牌:客户端使用获取的访问令牌向资源服务器请求受保护资源。
密码模式:
- 用户输入凭证:用户在客户端输入用户名和密码。
- 请求访问令牌:客户端将用户凭证(用户名和密码)发送到授权服务器的令牌端点,附带
grant_type=password
参数。 - 返回访问令牌:授权服务器验证凭证,返回访问令牌(和可选的刷新令牌)
- 使用访问令牌:客户端使用访问令牌请求受保护资源。
客户端凭证模式
- 请求访问令牌:客户端向授权服务器的令牌端点发送请求,包含
grant_type=client_credentials
、client_id
和client_secret
。 - 验证凭证:授权服务器验证客户端的凭证。
- 返回访问令牌:如果验证成功,授权服务器返回访问令牌。
- 使用访问令牌:客户端使用访问令牌请求受保护资源。
其中简化模式
的安全性较低,访问令牌直接暴露在url中。而密码模式
和客户端模式
,都是适用于信任的服务之间交互,也不需要跳转到第三方的授权页面。
三、Session共享问题
本项目的社交登录中,登录成功后,会将用户的信息保存到session中,在页面上也需要完成登录成功跳转回首页,展示用户昵称的功能。这样就引出了session共享
的问题:
用户的会话状态(Session)存储在服务器内存中,如果是在分布式的情况下,使用负载均衡器时,用户请求可能被路由到不同的服务器,以及在微服务架构或容器化部署中,每个服务实例可能在不同的环境中运行。若未采用共享会话存储机制,用户的会话可能无法在不同实例之间共享。
即使是在传统的单体架构下,也有可能存在session共享的问题,例如不同的域名,因为session只在当前自己的域名中生效。
如果需要解决session共享问题,可以有以下的解决思路:
- 做session同步。例如我现在有10台服务器,在第一台服务器上存有某个用户登录的session,在保存的时候向另外9台进行同步。
- 将session保存在客户端。因为不管有几台服务器,几个域名,当前登录的浏览器只有一个。
- 将其存入redis(引入springSession)。
- 使用分布式的jwt解决。
上述的解决方案中,第一个方案会使服务器的负担大大加重,而第二种是极其不安全的。目前使用较多的是通过jwt解决,而本项目中使用的是springSession
1、引入Spring Session
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
application.properties
:
spring.session.store-type=redis
spring.session.timeout=30m
2、配置Spring Session
我们需要对Spring Session进行自定义配置,首先设置序列化方式为json,还要将域名进行统一,范围放大(认证的域名是auth.gulimall.com
,而登录成功后跳转到的域名是gulimall.com
)参照Spring Session官方文档,还需要在启动类上加入@EnableRedisHttpSession
注意,只要是需要session共享的服务都需要加入这个自定义配置以及相关依赖
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("GULISESSIONID");
//自定义domain域名
serializer.setDomainName("gulimall.com");
return serializer;
}
/**
*
* @return
*/
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
同时页面也需要进行对应的修改,这里是首页页面的修改,其他页面的修改大同小异。
<li>
<a th:if="${session.loginUser != null}">欢迎:[[${session.loginUser == null? '':session.loginUser.nickname}]]</a>
<a href="http://auth.gulimall.com/login.html" th:if="${session.loginUser == null}">请先登录!</a>
</li>
四、单点登录
单点登录允许用户在一次登录后访问多个相关但独立的应用程序或服务,而无需再次输入凭证。例如百度,在百度搜索页面登录百度账号,此时进入贴吧,也是处于登录的状态,无需重复登录,即一处登录处处登录。
要实现单点登录,需要一个身份验证服务器
,主要负责处理登录:
@Controller
public class AuthController {
@PostMapping("/doLogin")
public String login(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam("url") String url,
HttpServletResponse response) {
if ("minmin".equals(username) && "123456".equals(password)) {
//生成jwt
String token = Jwts.builder()
.setSubject(username)
.setExpiration(new Date(System.currentTimeMillis() + 86400000))
.signWith(SignatureAlgorithm.HS512, "secret_key")
.compact();
//现在有两个客户端要登录,只要有其中一个登录成功了,就往浏览器中存一个cookie
response.addCookie(new Cookie("sso_token",token));
//跳回之前的页面
//url:http://127.0.0.1:8081/employees.html?token=5446541464123asd
return "redirect:"+url+"?token="+token;
}
return "login";
}
/**
* 跳转到登录页
* @return
*/
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url, Model model,
@CookieValue(value = "sso_token",required = false) String sso_token) {
if (sso_token != null) {
//说明之前其他用户登录过,直接重定向回资源页面
return "redirect:"+url+"?token="+sso_token;
}
model.addAttribute("url", url);
return "login";
}
}
身份验证服务器
的登录页面login.html
:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form action="/doLogin" method="post">
用户名:<input name="username"/> <br/>
密码:<input name="password" type="password"/><br/>
<!-- 隐藏的input框,记录跳转来源的url-->
<input type="hidden" name="url" th:value="${url}">
<input type="submit" value="登录">
</form>
</body>
</html>
客户端A
的服务器:
@Controller
public class ResourceController {
/**
* 无需登录
* @return
*/
@ResponseBody
@RequestMapping("/hello")
public String hello(){
return "hello";
}
/**
* 登录后才能访问
* @param model
* @return
*/
@GetMapping("/boss")
public String employees(Model model, HttpSession session, @RequestParam(value = "token",required = false) String token){
if (token != null){
//解析token,存入session
String username = Jwts.parser()
.setSigningKey("secret_key")
.parseClaimsJws(token)
.getBody()
.getSubject();
session.setAttribute("loginUser", username);
}
if (session.getAttribute("loginUser") == null){
//跳转到登录页面,连接中拼上自己的地址
return "redirect:http://127.0.0.1:8080/login.html?redirect_url=http://127.0.0.1:8082/boss";
}
ArrayList<String> list = new ArrayList<>();
list.add("John");
list.add("Mary");
model.addAttribute("employees", list);
return "list";
}
}
客户端B
的服务器:
@Controller
public class ResourceController {
/**
* 无需登录
* @return
*/
@ResponseBody
@RequestMapping("/hello")
public String hello(){
return "hello";
}
/**
* 登录后才能访问
* @param model
* @return
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session, @RequestParam(value = "token",required = false) String token){
if (token != null){
//解析token,存入session
String username = Jwts.parser()
.setSigningKey("secret_key")
.parseClaimsJws(token)
.getBody()
.getSubject();
session.setAttribute("loginUser", username);
}
if (session.getAttribute("loginUser") == null){
//跳转到登录页面,连接中拼上自己的地址
return "redirect:http://127.0.0.1:8080/login.html?redirect_url=http://127.0.0.1:8081/employees";
}
ArrayList<String> list = new ArrayList<>();
list.add("John");
list.add("Mary");
model.addAttribute("employees", list);
return "list";
}
}
客户端A
和客户端B
的页面list.html
:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>员工列表</title>
</head>
<body>
<h1>欢迎:[[${session.loginUser}]]</h1>
<ul>
<li th:each="emp:${employees}">姓名[[${emp}]]</li>
</ul>
</body>
</html>
假设此时用户需要在A客户端进行登录:
- 用户第一次访问
http:/127.0.0.1:8081/employees
,因为没有登录,token和session都为空,就会重定向到身份验证服务器
的登录页面,同时会将自己的url地址拼接在身份验证服务器
的登录页面的后面,便于登录完成后再重定向回来。
身份验证服务器
会根据路径跳转到登录页面,但是会先判断当前浏览器的cookie中是否存在一个键为sso_token
的值,如果存在说明用户已经在它处完成了登录,就不需要跳转到登录页面,而是直接重定向回来源地址,并且传回sso_token
的值。(来源地址的服务器进行校验,发现token存在,就放入session中)。- 在登录页面输入用户名和密码,进行校验,
身份验证服务器
会生成生成jwt
,进行两个操作:(1)、将jwt以sso_token
为键存入当前浏览器的cookie中,(2)、重定向回来源地址,并且在路径中拼入token为生成的jwt
- 资源地址进行校验,获取路径上的token,进行解析,存入session中,访问资源。
用户经历了上面的过程,在B客户端访问页面时,由于请求参数中没有token,第一次访问也没有存入session,所以回重定向到登录页面。
跳转到登录页面时,请求头中携带了浏览器保存的sso_token
和在A处登录时保存在浏览器的是一样的
所以无需进入登录页面,直接重定向回资源页面,发现token存在,就解析token,存入自己的session中。