目录
1 环境搭建
1.1 新建认证服务模块gulimall-auth-server
1.2 认证服务模块基础配置
1.2.1 pom.xml
1.2.2 yml配置
1.2.2.1 application.yml配置
1.2.2.2 bootstrap.yml配置
1.2.3 主类
1.3 SwitchHosts增加配置
1.4 认证页面搭建
1.5 网关配置
1.6 新增视图映射
2 验证码倒计时
2.1 注册页面
3 整合短信验证码
3.1 购买阿里云短信接口
3.2 PostMan测试短信接口
3.3 整合短信服务
3.3.1 新增HttpUtils.java
3.3.2 测试验证码发送
3.3.3 整合短信服务
3.3.3.1 抽取短信服务组件
3.3.3.2 抽取组件配置到application.yml配置文件
3.3.3.3 调用短信组件进行测试
4 验证码防刷校验
4.1 新增SmsSendController提供给其他服务调用
4.2 认证服务远程调用短信发送服务
4.2.1 远程调用短信发送接口
4.2.2 认证服务发送短信接口
4.2.3 前端调用发送验证码接口
4.2.4 目前短信验证码接口的缺陷
4.2.5 验证码防刷
4.2.5.1 引入redis依赖
4.2.5.2 yml配置redis相关信息
4.2.5.3 认证服务短信验证码功能实现
4.2.5.4 测试
5 注册页面相关功能实现
5.1 页面参数JSR303校验
5.2 会员服务,存储注册用户信息
5.2.1 接收注册用户信息vo
5.2.2 定义用户名和手机号重复异常
5.2.3 用户注册controller
5.2.4 MD5&盐值&BCrypt
5.2.5 用户服务注册接口service
5.3 认证服务注册接口
6 账号密码登录
6.1 接收前端传的登录信息对象
6.2 用户服务的登录接口
6.2.1 接收登录信息的vo
6.2.2 controller层
6.2.3 service层
6.3 认证服务的登录接口
6.3.1 Feign接口,远程调用用户服务
6.3.2 controller层
6.3.3 登录页修改
7 社交登录(OAuth2.0)
7.1 OAuth2.0简介
7.2 微博社交登录(身份认证耗时略过)
7.3 Gitee社交登录
7.3.1 在Gitee创建第三方应用
7.3.2 修改登录页面,获取Gitee授权码
7.3.3 授权成功后回调接口编写
8 分布式Session共享问题
8.1 session原理
8.2 分布式下session共享问题
8.3 分布式session解决方案原理
8.3.1 Session共享问题解决-session复制
8.3.2 Session共享问题解决-客户端存储
8.3.3 Session共享问题解决-hash一致性
8.3.4 Session共享问题解决-统一存储
8.3.5 Session共享问题解决-不同服务,子域session共享
8.4 SpringSession整合
8.4.1 引入依赖
8.4.2 配置session存储方式以及过期时间
8.4.3 开启SpringSession
8.4.4 测试
8.5 自定义SpringSession完成子域session共享
8.6 SpringSession原理
8.6.1 原理
8.6.2 页面效果完成
9 单点登录SSO
9.1 单点登录简介
9.2 许雪里单点登录效果演示
9.3 单点登录实现
9.3.1 单点登录服务端gulimall-test-sso-server
9.3.1.1 创建单点登录服务模块
9.3.1.2 pom.xml中相关依赖
9.3.1.3 application.properties配置
9.3.1.4 LoginController登录控制层
9.3.1.5 login.html登录页
9.3.2 客户端 gulimall-test-sso-client
9.3.2.1 创建客户端(client1)模块
9.3.2.2 pom.xml中相关依赖
9.3.2.3 application.properties配置
9.2.3.4 HelloController测试控制层
9.2.3.5 list.html员工列表页
9.3.3 客户端gulimall-test-sso-client2
9.3.3.1 创建客户端(client2)
9.3.4 host配置
9.3.5 测试
9.3.6 总结
这里SpingBoot的版本与其他模块保持一致用2.7.8。
引入gulimall-common模块依赖,并排除mybatis-plus-boot-starter
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.7.8
com.wen.gulimall
gulimall-auth-server
1.0
gulimall-auth-server
认证中心(社交登录、OAuth2.0、单点登录)
1.8
2021.0.5
com.wen.gulimall
gulimall-common
1.0
com.baomidou
mybatis-plus-boot-starter
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-web
org.springframework.cloud
spring-cloud-starter-openfeign
org.springframework.boot
spring-boot-devtools
runtime
true
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.springframework.cloud
spring-cloud-dependencies
${spring-cloud.version}
pom
import
org.springframework.boot
spring-boot-maven-plugin
org.projectlombok
lombok
注册中心、服务名、端口。
spring:
cloud:
nacos:
discovery:
server-addr: xxx.xxx.xxx.xxx:8848
application:
name: gulimall-auth-server
server:
port: 20000
配置中心、命名空间、服务名。
spring:
cloud:
nacos:
config:
server-addr: xxx.xxx.xxx.xxx:8848
namespace: 8664cc43-affa-4545-b82b-5a5d248ab872
application:
name: gulimall-auth-server
开启服务发现和远程调用。
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
添加认证服务的域名与ip映射:xxx.xxx.11.10 auth.gulimall.com
1. 将登录页面index.html复制到gulimall-auth-server/src/main/resources/templates/下,更名为login.html;
2. 将注册页面index.html复制到gulimall-auth-server/src/main/resources/templates/下,更名为reg.html;
3. 将登录页面和注册页面的静态资源放到nginx的/root/docker/nginx/html/static/目录下login文件夹和reg文件夹下;
4. 登录页面的src和href以/static/login/开头,注册页面的src和href以/static/reg/开头。
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
# 由以下的主机域名访问转发到商品服务
- Host=auth.gulimall.com
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
@GetMapping("/reg.html")
public String regPage(){
return "reg";
}
}
如果在controller中请求不做任何处理直接返回对应的视图是可以的,但是会出现很多空方法。这里就可以使用SpringMVC viewcontroller,将请求和页面进行映射,如下:
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/config/GulimallWebConfig.java
/**
* @author W
* @createDate 2023/9/14 14:24
* @description 请求直接跳转页面没有其他逻辑可以使用以下视图映射
*/
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("login.html").setViewName("login");
registry.addViewController("reg.html").setViewName("reg");
}
}
测试:
调整商城首页、登录页、注册页面的跳转。
1. 使用setTimeout()方法完成60s倒计时,可以参照以下网址:
https://www.w3school.com.cn/jsref/met_win_settimeout.asp
2. reg.html验证码相关代码:
(1)将请发送验证码下面第一个标签改为标签,改动行5050行,如下
发送验证码
1. 登录阿里云-》点击云市场-》了解更多-》搜索短信
2. 视频中老师购买短信的地址,如下:
https://market.aliyun.com/products/57126001/cmapi024822.html?spm=5176.21213303.J_6704733920.11.49fb3edaM8bteY&scm=20140722.S_market%40%40API%E5%B8%82%E5%9C%BA%40%40cmapi024822..ID_market%40%40API%E5%B8%82%E5%9C%BA%40%40cmapi024822-RL%E4%B8%89%E5%90%88%E4%B8%80%E7%9F%AD%E4%BF%A1-OR_main-V_2-P0_2#sku=yuncode18822000012
因为以上购买地址需要企业认证,我这里使用个人认证的阿里云账户,使用如下购买地址:
https://market.aliyun.com/products/57126001/cmapi00037415.html?spm=5176.730005.result.8.370a3524CZKeDx&innerSource=search_%E7%9F%AD%E4%BF%A1#sku=yuncode31415000020
3. 购买后可以在云市场首页-》买家中心-》进入管理控制台-》已购买的服务中看到自己购买的短信接口
1. 请求url:http://gyytz.market.alicloudapi.com/sms/smsSend
2. 请求方式:POST
2. 请求参数:
1)mobile 手机号
2)templateId 短信模板ID
3)smsSignId 短信前缀ID(签名ID)
4)param 短信模板变量 字符串格式:**key**:value,**key**:value。例如:**code**:12345,**minute**:5。
3. 简单身份认证,将AppCode放在Header中请求Header中添加的Authorization字段;配置Authorization字段的值为“APPCODE + 半角空格 +APPCODE值”。
格式如下:
Authorization:APPCODE AppCode值
测试结果:
手机收到短信:
不应该前端直接请求阿里云短信接口,容易泄露APPCODE,造成损失。
gulimall-third-party/src/main/java/com/wen/gulimall/thirdparty/util/HttpUtils.java
public class HttpUtils {
/**
* get
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @return
* @throws Exception
*/
public static HttpResponse doGet(String host, String path, String method,
Map headers,
Map querys)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpGet request = new HttpGet(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
return httpClient.execute(request);
}
/**
* post form
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param bodys
* @return
* @throws Exception
*/
public static HttpResponse doPost(String host, String path, String method,
Map headers,
Map querys,
Map bodys)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPost request = new HttpPost(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (bodys != null) {
List nameValuePairList = new ArrayList();
for (String key : bodys.keySet()) {
nameValuePairList.add(new BasicNameValuePair(key, bodys.get(key)));
}
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(nameValuePairList, "utf-8");
formEntity.setContentType("application/x-www-form-urlencoded; charset=UTF-8");
request.setEntity(formEntity);
}
return httpClient.execute(request);
}
/**
* Post String
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPost(String host, String path, String method,
Map headers,
Map querys,
String body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPost request = new HttpPost(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (StringUtils.isNotBlank(body)) {
request.setEntity(new StringEntity(body, "utf-8"));
}
return httpClient.execute(request);
}
/**
* Post stream
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPost(String host, String path, String method,
Map headers,
Map querys,
byte[] body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPost request = new HttpPost(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (body != null) {
request.setEntity(new ByteArrayEntity(body));
}
return httpClient.execute(request);
}
/**
* Put String
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPut(String host, String path, String method,
Map headers,
Map querys,
String body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPut request = new HttpPut(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (StringUtils.isNotBlank(body)) {
request.setEntity(new StringEntity(body, "utf-8"));
}
return httpClient.execute(request);
}
/**
* Put stream
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPut(String host, String path, String method,
Map headers,
Map querys,
byte[] body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPut request = new HttpPut(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (body != null) {
request.setEntity(new ByteArrayEntity(body));
}
return httpClient.execute(request);
}
/**
* Delete
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @return
* @throws Exception
*/
public static HttpResponse doDelete(String host, String path, String method,
Map headers,
Map querys)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpDelete request = new HttpDelete(buildUrl(host, path, querys));
for (Map.Entry e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
return httpClient.execute(request);
}
private static String buildUrl(String host, String path, Map querys) throws UnsupportedEncodingException {
StringBuilder sbUrl = new StringBuilder();
sbUrl.append(host);
if (!StringUtils.isBlank(path)) {
sbUrl.append(path);
}
if (null != querys) {
StringBuilder sbQuery = new StringBuilder();
for (Map.Entry query : querys.entrySet()) {
if (0 < sbQuery.length()) {
sbQuery.append("&");
}
if (StringUtils.isBlank(query.getKey()) && !StringUtils.isBlank(query.getValue())) {
sbQuery.append(query.getValue());
}
if (!StringUtils.isBlank(query.getKey())) {
sbQuery.append(query.getKey());
if (!StringUtils.isBlank(query.getValue())) {
sbQuery.append("=");
sbQuery.append(URLEncoder.encode(query.getValue(), "utf-8"));
}
}
}
if (0 < sbQuery.length()) {
sbUrl.append("?").append(sbQuery);
}
}
return sbUrl.toString();
}
private static HttpClient wrapClient(String host) {
HttpClient httpClient = new DefaultHttpClient();
if (host.startsWith("https://")) {
sslClient(httpClient);
}
return httpClient;
}
private static void sslClient(HttpClient httpClient) {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
X509TrustManager tm = new X509TrustManager() {
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
@Override
public void checkClientTrusted(X509Certificate[] xcs, String str) {
}
@Override
public void checkServerTrusted(X509Certificate[] xcs, String str) {
}
};
ctx.init(null, new TrustManager[] { tm }, null);
SSLSocketFactory ssf = new SSLSocketFactory(ctx);
ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
ClientConnectionManager ccm = httpClient.getConnectionManager();
SchemeRegistry registry = ccm.getSchemeRegistry();
registry.register(new Scheme("https", 443, ssf));
} catch (KeyManagementException ex) {
throw new RuntimeException(ex);
} catch (NoSuchAlgorithmException ex) {
throw new RuntimeException(ex);
}
}
}
@SpringBootTest
class GulimallThirdPartyApplicationTests {
...
@Test
void sendSms(){
String host = "https://gyytz.market.alicloudapi.com";
String path = "/sms/smsSend";
String method = "POST";
String appcode = "804dd153e2824f12a4045331b19xxxxx";
Map headers = new HashMap();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
Map querys = new HashMap();
querys.put("mobile", "1xxxxxxxxx5");
querys.put("param", "**code**:6789");
//smsSignId(短信前缀)和templateId(短信模板),可登录国阳云控制台自助申请。参考文档:http://help.guoyangyun.com/Problem/Qm.html
querys.put("smsSignId", "2e65b1bb3d054466b82f0c9d125465e2");
querys.put("templateId", "63698e3463bd490dbc3edc46a20c55f5");
Map bodys = new HashMap();
try {
/**
* 重要提示如下:
* HttpUtils请从\r\n\t \t* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java\r\n\t \t* 下载
*
* 相应的依赖请参照
* 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();
}
}
}
gulimall-third-party/src/main/java/com/wen/gulimall/thirdparty/component/SmsComponent.java
注意:host、path、smsSignId、templateId、appcode这些属性值从yml配置文件中获取。
@ConfigurationProperties(prefix = "spring.alicloud.sms")
@Data
@Component
public class SmsComponent {
private String host;
private String path;
private String smsSignId;
private String templateId;
private String appcode;
public void sendSms(String phone, String code){
String method = "POST";
Map headers = new HashMap();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
Map querys = new HashMap();
querys.put("mobile", phone);
querys.put("param", "**code**:"+code);
//smsSignId(短信前缀)和templateId(短信模板),可登录国阳云控制台自助申请。参考文档:http://help.guoyangyun.com/Problem/Qm.html
querys.put("smsSignId", smsSignId);
querys.put("templateId", templateId);
Map bodys = new HashMap();
try {
/**
* 重要提示如下:
* HttpUtils请从\r\n\t \t* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java\r\n\t \t* 下载
*
* 相应的依赖请参照
* 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();
}
}
}
1. gulimall-third-party模块,引入配置提示依赖
org.springframework.boot
spring-boot-configuration-processor
true
2. 新增配置
gulimall-third-party/src/main/resources/application.yml
spring:
...
alicloud:
sms:
host: https://gyytz.market.alicloudapi.com
path: /sms/smsSend
sms-sign-id: 2e65b1bb3d054466b82f0c9d125465e2
template-id: 63698e3463bd490dbc3edc46a20c55f5
appcode: 804dd153e2824f12a4045331b1976e20
@SpringBootTest
class GulimallThirdPartyApplicationTests {
...
@Resource
private SmsComponent smsComponent;
@Test
void testSendCode(){
smsComponent.sendSms("18xxxxxxx25","789456123");
}
}
验证码发送成功,手机收到验证码。
前端不直接调用第三方服务,短信接口提供给别的服务进行调用。
gulimall-third-party/src/main/java/com/wen/gulimall/thirdparty/controller/SmsSendController.java
@RestController
@RequestMapping("/sms")
public class SmsSendController {
@Resource
private SmsComponent smsComponent;
/**
* 前端不直接调用第三方服务,提供给别的服务进行调用
* @param phone
* @param code
* @return
*/
@GetMapping("/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code){
smsComponent.sendSms(phone,code);
return R.ok();
}
}
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/feign/ThirdPartyFeignService.java
@FeignClient("gulimall-third-party")
public interface ThirdPartyFeignService {
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
}
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
@Resource
private ThirdPartyFeignService thirdPartyFeignService;
@ResponseBody
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone){
String code = generateCode(5);
// 验证码只能是数字
thirdPartyFeignService.sendCode(phone,code);
return R.ok();
}
// 生成纯数字随机验证码
public static String generateCode(int length) {
StringBuilder sbr = new StringBuilder();
if(length <= 0) {
throw new RuntimeException("验证码长度不能为负数");
}
Random random = new Random();
for (int i = 0; i < length; i++) {
//随机生成一个 0-9 的正整数
int index = random.nextInt(10) ;
sbr.append(index);
}
return sbr.toString();
}
}
注意:我使用的这个阿里云短信接口只支持纯数字验证码,这里自定义了验证码生成方法。
发送短信验证码成功:
问题:
1. 暴漏了验证码发送的请求,可能会被恶意攻击,照成损失
2. 发送验证码虽然有60s的倒计时,但是刷新页面可以重新发送,没有限制
要求:
1. 验证码必须要有严格意义上的60s重新发送;
2. 验证码必须要有有效期
1. 发送验证码之前先去redis查询,是否间隔超过60s,否提示错误信息不允许发送验证码;
2. 将验证码存放到redis设置过期时间10min,并存入当前时间。
org.springframework.boot
spring-boot-starter-data-redis
spring:
...
redis:
host: 1xx.x.x.10
port: 6379
redis中短信验证码key前缀
gulimall-common/src/main/java/com/wen/common/constant/AuthServerConstant.java
public class AuthServerConstant {
/**
* 短信验证码在redis中的前缀
*/
public static final String SMS_CODE_CACHE_PREFIX="sms:code:";
}
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
@Resource
private ThirdPartyFeignService thirdPartyFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@ResponseBody
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone){
// 1. 接口防刷
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
if(StringUtils.isNotEmpty(redisCode)){
long l = Long.parseLong(redisCode.split("_")[1]);
if(System.currentTimeMillis()-l<60000){
// 60秒内不能在发送
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}
// 2. 验证码的再次校验,验证码是暂时存储无需持久化,所以存放在redis。key-phone,value-code
String code = generateCode(5);
// redis中缓存验证码,防止同一个手机号在60秒内再次发送验证码
stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,code+"_"+System.currentTimeMillis(),10, TimeUnit.MINUTES);
// 验证码只能是数字
thirdPartyFeignService.sendCode(phone,code);
return R.ok();
}
public static String generateCode(int length) {
StringBuilder sbr = new StringBuilder();
if(length <= 0) {
throw new RuntimeException("验证码长度不能为负数");
}
Random random = new Random();
for (int i = 0; i < length; i++) {
//随机生成一个 0-9 的正整数
int index = random.nextInt(10) ;
sbr.append(index);
}
return sbr.toString();
}
}
异常码和异常信息
gulimall-common/src/main/java/com/wen/common/exception/BizCodeEnum.java
注册页面
gulimall-auth-server/src/main/resources/templates/reg.html
点击注册页面发送验证码
60s内再次发送同一个手机号的验证码
认证服务引入JSR303数据校验相关依赖
org.springframework.boot
spring-boot-starter-validation
注册页面接收参数对象UserRegistVo,进行JSR303数据校验
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/vo/UserRegistVo.java
@Data
public class UserRegistVo {
@NotEmpty(message = "用户名必须提交")
@Length(min = 6,max = 18,message = "用户名必须是6-8位字符")
private String userName;
@NotEmpty(message = "密码必须填写")
@Length(min = 6,max = 18,message = "密码必须是6-8位字符")
private String password;
@NotEmpty(message = "手机号必须填写")
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$", message = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String code;
}
注册页面表单提交控制器
@Valid注解用于表示校验的对象,BindingResult用来接收校验异常信息
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
...
/**
* 1.可以使用BindingResult接收校验异常信息,也可以使用全局异常处理来捕获处理
* 2. RedirectAttributes redirectAttributes :模拟重定向携带数据
* // todo:重定向携带数据,利用session原理。将数据放在session中。只要跳到下一个页面取出这个数据后,session里面的数据就会删掉。(分布式情况下有问题)
* // todo:分布式下的session问题。
* @param vo
* @param result
* @return
*/
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes){
if(result.hasErrors()){
// 会出现重复key错误
Map errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
redirectAttributes.addFlashAttribute("errors",errors);
// 校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
// 调用远程服务注册
return "redirect:/login.html";
}
}
注册表单代码调整,需要注释掉表单提交按钮单击事件的代码
gulimall-auth-server/src/main/resources/templates/reg.html
gulimall-member/src/main/java/com/wen/gulimall/member/vo/MemberRegistVo.java
@Data
public class MemberRegistVo {
private String userName;
private String password;
private String phone;
}
gulimall-member会员服务,用户注册逻辑:
(1)如果当前注册会员名或手机号重复,业务层抛出相应的自定义异常,控制层捕获异常并封装返回相应的错误信息;
(2)如果没有被注册过,保存传递过来的注册信息,设置默认会员等级和创建时间。
gulimall-member/src/main/java/com/wen/gulimall/member/exception/PhoneExistException.java
public class PhoneExistException extends RuntimeException{
public PhoneExistException() {
super("手机号已存在");
}
}
gulimall-member/src/main/java/com/wen/gulimall/member/exception/UsernameExistException.java
public class UsernameExistException extends RuntimeException{
public UsernameExistException() {
super("用户名已存在");
}
}
gulimall-member/src/main/java/com/wen/gulimall/member/controller/MemberController.java
@RestController
@RequestMapping("member/member")
public class MemberController {
@Autowired
private MemberService memberService;
...
@PostMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo){
try {
memberService.regist(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();
}
}
定义异常编码和异常信息
gulimall-common/src/main/java/com/wen/common/exception/BizCodeEnum.java
public enum BizCodeEnum {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验异常"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
USER_EXIST_EXCEPTION(15001,"用户已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已存在");
...
}
加密算法有可逆和不可逆之分,密码加密应该使用不可逆加密算法安全性更高。
1. MD5&MD5盐值加密
2. BCryptPasswordEncoder
spring security中的BCryptPasswordEncoder方法采用SHA-256 +随机盐+密钥对密码进行加密。SHA系列是Hash算法,不是加密算法,使用加密算法意味着可以解密(这个与编码/解码一样),但是采用Hash处理,其过程是不可逆的。
优势:
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 加密
String encode = passwordEncoder.encode("123456");
尽管同一密码每次加密后的密文不一样,但可以通过matches进行匹配。它的原理是把需要配对的密码经过同一个hash函数计算,把计算得到的hash值到数据库中匹配,相同的hash值则说明是同一个密码。
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 加密
String encode = passwordEncoder.encode("123456");
// 使用明文和密文进行比较
boolean matches = passwordEncoder.matches("123456", encode);
gulimall-member/src/main/java/com/wen/gulimall/member/service/MemberService.java
public interface MemberService extends IService {
...
void regist(MemberRegistVo vo);
void checkUsernameUnique(String username) throws UsernameExistException;
void checkPhoneUnique(String phone) throws PhoneExistException;
}
gulimall-member/src/main/java/com/wen/gulimall/member/service/impl/MemberServiceImpl.java
@Service("memberService")
public class MemberServiceImpl extends ServiceImpl implements MemberService {
@Resource
private MemberLevelDao memberLevelDao;
...
@Override
public void regist(MemberRegistVo vo) {
MemberEntity memberEntity = new MemberEntity();
// 设置默认等级
MemberLevelEntity memberLevelEntity = memberLevelDao.getDefaultLevel();
memberEntity.setLevelId(memberLevelEntity.getId());
checkPhoneUnique(vo.getPhone());
checkUsernameUnique(vo.getUserName());
// 检查用户名和邮箱是否唯一
memberEntity.setUsername(vo.getUserName());
memberEntity.setMobile(vo.getPhone());
// 设置密码加密
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());
memberEntity.setPassword(encode);
// 其他的默认信息
// 保存
this.baseMapper.insert(memberEntity);
}
@Override
public void checkUsernameUnique(String username) throws UsernameExistException{
Long count = this.baseMapper.selectCount(new QueryWrapper().eq("username", username));
if(count>0L){
throw new UsernameExistException();
}
}
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException{
Long count = this.baseMapper.selectCount(new QueryWrapper().eq("mobile", phone));
if(count>0L){
throw new PhoneExistException();
}
}
}
Feign接口
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/feign/MemberFeignService.java
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
R regist(@RequestBody UserRegistVo vo);
}
注册接口
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
@Resource
private ThirdPartyFeignService thirdPartyFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private MemberFeignService memberFeignService;
...
/**
* 1.可以使用BindingResult接收校验异常信息,也可以使用全局异常处理来捕获处理
* 2. RedirectAttributes redirectAttributes :模拟重定向携带数据
* // todo:重定向携带数据,利用session原理。将数据放在session中。只要跳到下一个页面取出这个数据后,session里面的数据就会删掉。(分布式情况下有问题)
* // todo:分布式下的session问题。
* @param vo
* @param result
* @return
*/
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes){
if(result.hasErrors()){
// todo 会出现重复key错误
Map errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
redirectAttributes.addFlashAttribute("errors",errors);
// 校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
// 调用远程服务注册
// 1. 校验验证码
String code = vo.getCode();
String s = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
if(!StringUtils.isEmpty(s)){
if(code.equals(s.split("_")[0])){
// 删除验证码,只能用一次;令牌机制
stringRedisTemplate.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 errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
Map errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
// 校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
Map errors = new HashMap<>();
errors.put("code","验证码已失效,请重新发送");
redirectAttributes.addFlashAttribute("errors",errors);
// 校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//return "redirect:/login.html";
}
注册页面,添加远程会员服务注册异常信息提示,用户名重复、手机号重复
gulimall-auth-server/src/main/resources/templates/reg.html
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/vo/UserLoginVo.java
@Data
public class UserLoginVo {
private String loginAccount;
private String password;
}
提供给认证服务远程调用。
会员服务的接收登录信息的vo属性要和认证服务接收前端登录信息vo的属性一致
gulimall-member/src/main/java/com/wen/gulimall/member/vo/MemberLoginVo.java
@Data
public class MemberLoginVo {
private String loginAccount;
private String password;
}
gulimall-member/src/main/java/com/wen/gulimall/member/controller/MemberController.java
@RestController
@RequestMapping("member/member")
public class MemberController {
@Autowired
private MemberService memberService;
...
@PostMapping("/login")
public R login(@RequestBody MemberLoginVo vo){
MemberEntity memberEntity = memberService.login(vo);
if(memberEntity!=null){
return R.ok();
}else {
return R.error(BizCodeEnum.LOGINACCOUNT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnum.LOGINACCOUNT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
}
异常信息
gulimall-common/src/main/java/com/wen/common/exception/BizCodeEnum.java
public enum BizCodeEnum {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验异常"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
USER_EXIST_EXCEPTION(15001,"用户已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已存在"),
LOGINACCOUNT_PASSWORD_INVAILD_EXCEPTION(15003,"账号或密码错误");
...
}
gulimall-member/src/main/java/com/wen/gulimall/member/service/MemberService.java
public interface MemberService extends IService {
...
MemberEntity login(MemberLoginVo vo);
}
gulimall-member/src/main/java/com/wen/gulimall/member/service/impl/MemberServiceImpl.java
@Service("memberService")
public class MemberServiceImpl extends ServiceImpl implements MemberService {
@Resource
private MemberLevelDao memberLevelDao;
...
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginAccount = vo.getLoginAccount();
String password = vo.getPassword();
// 用户名或手机号登录
MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper().eq("username", loginAccount)
.or().eq("mobile", loginAccount));
if(memberEntity == null){
// 登录失败
return null;
}else {
String passwordDb = memberEntity.getPassword();
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 匹配密码
boolean matches = passwordEncoder.matches(password, passwordDb);
if (matches){
return memberEntity;
}else {
return null;
}
}
}
}
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/feign/MemberFeignService.java
@FeignClient("gulimall-member")
public interface MemberFeignService {
...
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
}
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
@Resource
private ThirdPartyFeignService thirdPartyFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private MemberFeignService memberFeignService;
...
@PostMapping("/login")
public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){
// 远程登录
R login = memberFeignService.login(vo);
if(login.getCode() == 0){
// 成功
return "redirect:http://gulimall.com";
}else {
Map errors = new HashMap<>();
errors.put("msg",login.getData("msg",new TypeReference(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
}
登录以表单的方式提交
gulimall-auth-server/src/main/resources/templates/login.html
QQ、微博、github等网站的用户量非常大,别的网站为了简化自我网站的登录与注册逻辑,引入社交登录功能。
步骤:
1)用户点击QQ按钮;
2)引导跳转到QQ授权页;
3)用户主动点击授权,跳回之前的网页。
在另外的服务提供者上的信息, 而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
享等) , 为了保护用户数据的安全和隐私, 第三方网站访问用户数据前都需要显式的向
用户征求授权。
( A) 用户打开客户端以后, 客户端要求用户给予授权。
( B) 用户同意给予客户端授权。
( C) 客户端使用上一步获得的授权, 向认证服务器申请令牌。
( D) 认证服务器对客户端进行认证以后, 确认无误, 同意发放令牌。
( E) 客户端使用令牌, 向资源服务器申请获取资源。
( F) 资源服务器确认令牌无误, 同意向客户端开放资源。
以登录CSDN为例:
1. 微博开放平台配置
(1)https://open.weibo.com/-》登录微博-》微连接-》网站接入-》立即接入-》创建应用
(2)高级信息-》OAuth2.0授权设置
1)授权回调页:http://auth.gulimall.com//oauth2.0/weibo/success
2)取消授权回调页:http://gulimall.com/fail
(3)OAuth2.0授权认证文档:https://open.weibo.com/wiki/授权机制说明
1)修改微博登录的a标签跳转地址:https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI
修改client_id为创建应用的基本信息中App Key,redirect_uri为授权回调页地址。
2)用户同意微博授权登录后跳转到回调页并带上授权码(code=xxxxx)
3)根据授权码code换取Access Token,注意code只能使用一次。
可以使用PostMan访问以下地址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
进行Access Token获取测试,注意获取Access Token的请求方式是POST。
返回结果:
{
"access_token": "SlAV32hkKG",
"remind_in": 3600,
"expires_in": 3600
}
4)可以根据access_token访问微博授权的接口,比如获取用户信息等,用户已有权限访问的微博接口可以访问地址https://open.weibo.com/apps/2129105835/privilege【access_token有存活时间,可重复使用】查看,即在我的应用-》接口管理-》已有权限中可以查看微博已授权通过access_token可以访问的接口。
注意:换取access token需要的参数client_secret是必须保密的,同时通过access token获取用户相关信息等也需要对access token进行保密,所以换取access token应该通过后台进行。
换取Access_Token:
微博社交登录流程:
微博开放平台身份认证麻烦,这里使用Gitee测试社交登录。
1. https://gitee.com/explore登录Gitee
2. 点击设置-》第三方应用-》创建应用(填好必填信息提交)
3. 点击自己创建好的应用gulimall-shop可以查看应用详情(Client ID、Client Secret、应用回调地址等)
获取Gitee授权码可以参考Gitee的OAuth文档:Gitee OAuth 文档
注意:Gitee的图片可以访问地址https://e-assets.gitee.com/gitee-community-web/_next/static/media/logo-black.0c964084.svg获取,保存到nginx静态资源相关目录下(/nginx/html/static/login/JD_img/)。
新增社交登录用户对象GiteeSocialUser存放授权登录成功获取的accessToken相关信息。这个对象可以放到公共模块下便于远程调用。
@Data
public class GiteeSocialUser {
private String access_token;
private String token_type;
private String expires_in;
private String refresh_token;
private String scope;
private String created_at;
}
社交登录成功回调,需要远程调用member服务进行认证
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/OAuth2Controller.java
@Slf4j
@Controller
@RequestMapping("/oauth2.0")
public class OAuth2Controller {
@Resource
private MemberFeignService memberFeignService;
/**
* 社交登录成功回调
* @param code
* @return
* @throws Exception
*/
@GetMapping("/gitee/success")
public String gitee(@RequestParam("code") String code, HttpSession session) throws Exception {
Map param = new HashMap<>();
param.put("grant_type","authorization_code");
param.put("client_id","d12cc89d9c15e36a43edd0a79011a95e5764888f3538ea4eff9c5fc23afdbdb6");
param.put("client_secret","409692b27e5f16a14e6fe4e1cbb053efaea85d7952e11cbb5c43b83cbe6ffd10");
param.put("redirect_uri","http://auth.gulimall.com/oauth2.0/gitee/success");
param.put("code",code);
// 1. 根据code换取accessToken
HttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", new HashMap<>(), new HashMap<>(), param);
if(response.getStatusLine().getStatusCode() == 200){
// 获取到accessToken
String jsonStr = EntityUtils.toString(response.getEntity());
GiteeSocialUser giteeSocialUser = JSON.parseObject(jsonStr, GiteeSocialUser.class);
// 知道当前是哪个社交用户
// 1)当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息账号,以后这个社交账号就对应指定的会员)
// 登录或注册这个社交用户
R oauth2Login = memberFeignService.oauth2Login(giteeSocialUser);
if(oauth2Login.getCode() == 0){
MemberRespVo data = oauth2Login.getData("data", new TypeReference() {
});
log.info("登录成功,用户:{}",data);
// 1. 第一次使用session,命令浏览器保存卡号。JSESSIONID这个cookie;
// 以后浏览器访问哪个网站都会带上这个网站的cookie;
// 子域之间;gulimall.com auth.gulimall.com order.gulimall.com
// 发卡时(指定域名为父域名),即使子域系统发的卡,也能让父域直接使用。
session.setAttribute("loginUser",data);
// 2. 登录成功就跳回首页
return "redirect:http://gulimall.com";
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
}
远程调用member服务进行认证的openFeign接口oauth2Login
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/feign/MemberFeignService.java
@FeignClient("gulimall-member")
public interface MemberFeignService {
...
@PostMapping("/member/member/oauth2/login")
R oauth2Login(@RequestBody GiteeSocialUser socialUser) throws Exception;
}
member服务的社交登录接口controller层
gulimall-member/src/main/java/com/wen/gulimall/member/controller/MemberController.java
@RestController
@RequestMapping("member/member")
public class MemberController {
@Autowired
private MemberService memberService;
...
@PostMapping("/oauth2/login")
public R oauth2Login(@RequestBody GiteeSocialUser socialUser) throws Exception {
MemberEntity entity = memberService.login(socialUser);
if(entity!=null){
return R.ok().setData(entity);
}else {
return R.error(BizCodeEnum.LOGINACCOUNT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnum.LOGINACCOUNT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
}
涉及的异常枚举信息,gulimall-common/src/main/java/com/wen/common/exception/BizCodeEnum.java
public enum BizCodeEnum {
...
LOGINACCOUNT_PASSWORD_INVAILD_EXCEPTION(15003,"账号或密码错误");
...
}
member服务的社交登录接口servicer层
gulimall-member/src/main/java/com/wen/gulimall/member/service/MemberService.java
public interface MemberService extends IService {
...
MemberEntity login(GiteeSocialUser socialUser) throws Exception;
}
gulimall-member/src/main/java/com/wen/gulimall/member/service/impl/MemberServiceImpl.java
这里需要判断该用户是否使用该社交账号登录过,所以需要子啊数据库的ums_member表中保存这叫登录信息,这里需要增加三个字段:social_uid(社交用户的唯一id)、access_token(访问令牌)、expires_in(访问令牌的过期时间)。
这里的处理逻辑和老师视频中的不太一样,因为获取access_token返回的相关信息中没有id,所以这里要先根据access_token获取gitee用户id。
如下,获取的用户信息中没有用户性别,代码中暂且不设置性别信息。
具体代码如下:
@Service("memberService")
public class MemberServiceImpl extends ServiceImpl implements MemberService {
...
@Override
public MemberEntity login(GiteeSocialUser socialUser) throws Exception {
// 注册和登录合并逻辑
Map query = new HashMap<>();
query.put("access_token",socialUser.getAccess_token());
// gitee获取accessToken没有返回登录用户id,获取社交登录id
HttpResponse response = HttpUtils.doGet("https://gitee.com", "/api/v5/user","get", new HashMap<>(), query);
if(response.getStatusLine().getStatusCode() == 200){
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
String id = jsonObject.getString("id");
String name = jsonObject.getString("name");
// 判断当前社交用户是否已经登录过系统
MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper().eq("social_uid", id));
if(memberEntity!=null){
// 该用户已经注册,修改访问令牌、令牌过期时间
MemberEntity update = new MemberEntity();
update.setId(memberEntity.getId());
update.setAccessToken(socialUser.getAccess_token());
update.setExpiresIn(socialUser.getExpires_in());
this.baseMapper.updateById(update);
memberEntity.setAccessToken(socialUser.getAccess_token());
memberEntity.setExpiresIn(socialUser.getExpires_in());
return memberEntity;
}else {
// 没有查询到当前社交用户对应的记录我们需要注册一个
MemberEntity regist = new MemberEntity();
regist.setSocialUid(id);
regist.setNickname(name);
// Gitee返回的用户信息没有性别,所以这里不设置
regist.setAccessToken(socialUser.getAccess_token());
regist.setExpiresIn(socialUser.getExpires_in());
// 插入用户
this.baseMapper.insert(regist);
return regist;
}
}
return null;
}
}
Session是指在服务器端存储用户信息的一种技术。当用户第一次访问网站时,服务器会为该用户创建一个Session对象,并生成一个Session ID,浏览器将Session ID保存在Cookie中(jsessionid),下次访问网站时会自动将Cookie中的Session ID发送给服务器。服务器接收到Session ID后,会根据Session ID找到对应的Session对象,将用户信息保存在Session对象中。
分布式下session共享问题:
(1)相同服务:由Session原理,可知Session时保存在服务器端的一种技术,相同服务不同服务器,Session是不共享的。
(2)不同服务:获取Session对象是根据Cookie中保存的JSESSIONID去服务器获取的,不同的会话Session ID不同对应的Session对象也不同。
保证相同服务存储的session一致。
- 优点
- web-server(Tomcat)原生支持,只需要修改配置文件。
- 缺点
- session同步需要数据传输,占用大量的网络带宽,降低了服务器群的业务处理能力;
- 任意一台web-server保存的数据都是所有web-server的session总和,受到内存限制无法水平扩展更多的web-server;
- 大型分布式集群情况下,由于所有web-server都全量保存数据,所以此方案不可取。
- 优点
- 服务器不需存储session,用户保存自己的session信息到cookie中。节省服务端资源。
- 缺点
- 都是缺点,这只是一种思路。
具体如下:
》每次http请求,携带用户在cookie中的完整信息,浪费网络带宽;
》session数据放在cookie中,cookie有长度限制4K,不能保存大量信息;
》session数据放在cookie中,存在泄漏、篡改、窃取等安全隐患。
使用用户ip地址或用户id来做负载均衡,使某一用户永远访问的是同一台服务器。
- 优点:
- 只需要改nginx配置,不需要修改应用代码;
- 负载均衡,只要hash属性的值分布是均匀的,多台web-server的负载是均衡的;
- 可以支持web-server水平扩展(session同步法是不行的,受内存限制)。
- 缺点
- session还是存在web-server中的,所以web-server重启可能导致部分session丢失,影响业务,如部分用户需要重新登录;
- 如果web-server水平扩展,rehash后session重新分布,也会有一部分用户路由不到正确的session;
- 但是以上缺点问题也不是很大,因为session本来都是有有效期的。所以这两种反向代理的方式可以使用。
- 优点:
- 没有安全隐患;
- 可以水平扩展,数据库/缓存水平切分即可;
- web-server重启或者扩容都不会有session丢失。
- 不足
- 增加了一次网络调用,并且需要修改应用代码;如将所有的getSession方法替换为从Redis查数据的方式。redis获取数据比内存慢很多;
- 上面缺点可以用SpringSession完美解决 。
在存入session时,将jsessionid的作用域提升至最大,例如由auth.gulimall.com->.gulimall.com,那么gulimall.com以及其下面的所有子域都可以拿到这个jsessionid去服务器中获取session,可以实现不同服务之间的session共享。
解决方法:
前端放大域名,后端通过redis统一存储。
gulimall-auth-server/pom.xml
org.springframework.session
spring-session-data-redis
gulimall-auth-server/src/main/resources/application.yml
spring:
redis:
host: 172.1.11.10
port: 6379
session:
store-type: redis # session存储方式
server:
servlet:
session:
timeout: 30m # session过期时间
在启动类上或配置类上添加以下注解:
@EnableRedisHttpSession // 整合redis作为session存储
@EnableRedisHttpSession // 整合redis作为session存储
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
gulimall-product服务添加同上配置整合SpringSession,进行测试:
1. gulimall-product/src/main/resources/templates/index.html中获取登录用户,如下:
你好,请登录[[${session.loginUser.nickname}]] 2. 手动扩大session作用域,auth.gulimall.com->.gulimall.com
使用的jdk序列化不便于阅读,建议使用JSON序列化:
自定义session配置,修改为JSON序列化并放大session作用域。这里和老师视频中讲的有点不同,我把GulimallSessionConfig放到公共模块(gulimall-common)用于其他服务,不需要每个服务在进行session配置类编写。
公共模块引入SpringSession相关依赖,依赖版本根据SpringBoot版本而定,我这里SpringBoot版本是2.7.8,SpringSession版本可以去gulimall-auth-server的Dependencies中查看。
gulimall-common/pom.xml
org.springframework.session
spring-session-data-redis
2.7.0
gulimall-common/src/main/java/com/wen/common/config/GulimallSessionConfig.java
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("GULISESSION");
// 放大session作用域
serializer.setDomainName("gulimall.com");
return serializer;
}
// JSON序列化存储到redis
@Bean
public RedisSerializer
公共模块需要开启自动配置,才能将公共模块的配置类用于其他服务(其他服务需要引入公共模块gulimall-common)。
spring-core包里定义了SpringFactoriesLoader类,这个类实现了检索META-INF/spring.factories文件,并获取指定接口的配置的功能。
gulimall-common/src/main/resources/META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.wen.common.config.GulimallSessionConfig
测试,登录后可以自动跳回首页并显示登录用户名。注意对首页的session获取登录用户进行非空判断。
/**
* 核心原理
* @EnableRedisHttpSession 导入 RedisHttpSessionConfiguration配置
* 1.给容器中添加一个组件
* SessionRepository=>RedisIndexedSessionRepository->redis操作session。session的增删改查
* 2.SessionRepositoryFilter-》Filter: session存储过滤器;每个请求过来都必须经过filter
* (1)创建时从容器中自动获取到SessionRepository;
* (2)原始的request和response都被包装。SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper
* (3)以后获取session由request.getSession();
* (4)wrappedRequest.getSession();->SessionRepository中获取到的。
* 装饰者模式: 参考教程:https://www.jianshu.com/p/04a3bec6220c
*
* 自动延期:redis中的数据也是有过期时间的
*/
1. 网关模块报错解决:
注意:由于SpringSession的配置GulimallSessionConfig.java放到了公共模块,网关(gulimall-gateway)也引入了公共模块,会报BeanCreationException异常,错误信息如下:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'cookieSerializer' defined in class path resource 。所以在网关模块的启动类上排除SpringSession的配置,如下图:
2. 将使用用户密码登录成功后的用户信息放到session
使用用户密码登录成功后,当前登录的用户信息也要放到session中,gulimall-product在整合SpringSession后,就可以在首页拿到当前登录用户信息。session相关的key放到常量类中,gulimall-member(会员服务)的用户密码登录成功,返回登录用户信息。
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
gulimall-common/src/main/java/com/wen/common/constant/AuthServerConstant.java
public class AuthServerConstant {
/**
* 短信验证码在redis中的前缀
*/
public static final String SMS_CODE_CACHE_PREFIX = "sms:code:";
public static final String LOGIN_USER = "loginUser";
}
gulimall-member/src/main/java/com/wen/gulimall/member/controller/MemberController.java
3. gulimall-product商城首页优化
4. 登录成功后再次访问登录页应该跳回首页
登录成功后,浏览器访问http://auth.gulimall.com/login.html仍然可以进入登录页再次登录。所以在用户访问登录页时,就要判断用户是否已经登录,如果用户已经登录就要重定向到首页,未登录才可进行登录。
由于用户的登录页之前设置了视图映射,这里注释,在controller层对登录页进行逻辑处理,如下:
gulimall-auth-server/src/main/java/com/wen/gulimall/auth/controller/LoginController.java
@Controller
public class LoginController {
...
@GetMapping("/login.html")
public String login(HttpSession session){
Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);
if(attribute == null){
return "login";
}else {
return "redirect:http://gulimall.com";
}
}
}
5. 注册保存用户昵称
登陆成功后首页展示的是用户昵称,当时注册时没有保存昵称,这里补充一下。
6. 修改搜索页面和详情页面的登录状态,搜索服务整合springsession
搜索服务整合SpringSession,因为引入了gulimall-common,不需要在引入redis、SpringSession相关依赖,也不需要编写SpringSession配置类,这些都在公共模块配置好了,gulimall-search搜索服务yml中进行相关配置,如下:
修改搜索页面登录状态,如下:
gulimall-search/src/main/resources/templates/list.html
修改详情页登录状态,如下:
gulimall-product/src/main/resources/templates/item.html
1.SpringSession+扩大子域 适用于单系统分布式集群的登录。
2.多系统单点登录,使用SpringSession时Session作用域最多只能放大到一级域名,不可能放大到.com让世界上所有系统通用。
3.多系统-单点登录的效果:
(1)一处登录处处登录;
(2)一处退出处处退出。
许雪里单点登录开源框架地址:许雪里单点登录开源框架https://gitee.com/xuxueli0323/xxl-sso?_from=gitee_search
核心:统一的认证服务器
实现效果:多个系统即使域名不一致,也可以获得同一个用户的票据。
(1)一个统一认证的服务器ssoserver.com;
(2)其他系统想登录去ssoserver.com登录,登录成功后跳转回来;
(3)一处登录处处登录,一处退出处处退出;
(4)全部系统登录成功后统一一个xxl_sso_sessionid。
# 打包命令
mvn clean package -Dmaven.skip.test=true
# jar包运行命令
java -jar xxl-sso-server-1.1.1-SNAPSHOT.jar
java -jar xxl-sso-web-sample-springboot-1.1.1-SNAPSHOT.jar --server.port=8081
java -jar xxl-sso-web-sample-springboot-1.1.1-SNAPSHOT.jar --server.port=8082
单点登录流程如下:
注意:这里SpringBoot使用2.7.8版本,java使用1.8版本,和其他服务保持一致,创建好后修改pom.xml文件。
(thymleaf、redis)
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-starter-data-redis
server.port=8080
# redis端口默认6379,不用配置
spring.redis.host=1xx.xxx.xxx.10
gulimall-test-sso-server/src/main/java/com/wen/gulimall/ssoserver/controller/LoginController.java
package com.wen.gulimall.ssoserver.controller;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
@Controller
public class LoginController {
@Resource
private StringRedisTemplate stringRedisTemplate;
@ResponseBody
@GetMapping("/userInfo")
public String userInfo(String token){
String s = stringRedisTemplate.opsForValue().get(token);
return s;
}
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url") String url, Model model, @CookieValue(name = "sso_token",required = false) String sso_token){
if(!StringUtils.isEmpty(sso_token)){
// 说明之前登录过,浏览器留下了痕迹
return "redirect:"+url+"?token="+sso_token;
}
model.addAttribute("url",url);
return "login";
}
@PostMapping("/doLogin")
public String doLogin(String username, String password, String url, HttpServletResponse response){
if(!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){
// 登录成功,跳转到之前访问路径
String uuid = UUID.randomUUID().toString();
uuid = uuid.replace("-","");
// 存储登陆成功的用户
stringRedisTemplate.opsForValue().set(uuid,username);
Cookie cookie = new Cookie("sso_token",uuid);
response.addCookie(cookie);
return "redirect:"+url+"?token="+uuid;
}
return "login";
}
}
gulimall-test-sso-server/src/main/resources/templates/login.html
登录页
注意:这里SpringBoot使用2.7.8版本,java使用1.8版本,和其他服务保持一致,创建好后修改pom.xml文件。
(thymleaf、redis)
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-starter-data-redis
server.port=8081
# 单点登录服务器地址
sso.server.url=http://ssoserver.com:8080/login.html
spring.redis.host=1xx.xxx.xxx.10
gulimall-test-sso-client/src/main/java/com/wen/gulimall/ssoclient/controller/HelloController.java
package com.wen.gulimall.ssoclient.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.List;
@Controller
public class HelloController {
@Value("${sso.server.url}")
private String ssoServerUrl;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 无需登录就可访问
* @return
*/
@ResponseBody
@GetMapping("/hello")
public String hello(){
return "hello";
}
@GetMapping("/employees")
public String employees(Model model, HttpSession session,@RequestParam(required = false) String token){
if(!StringUtils.isEmpty(token)){
RestTemplate restTemplate = new RestTemplate();
ResponseEntity forEntity = restTemplate.getForEntity("http://ssoserver.com:8080/userInfo?token=" + token, String.class);
String body = forEntity.getBody();
session.setAttribute("loginUser",body);
}
Object loginUser = session.getAttribute("loginUser");
if(loginUser==null){
// 没登录,跳转到登录服务器进行登录
return "redirect:"+ssoServerUrl+"?redirect_url=http://client1.com:8081/employees";
}else {
List emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps", emps);
return "list";
}
}
}
gulimall-test-sso-client/src/main/resources/templates/list.html
老板列表
欢迎,[[${session.loginUser}]]
- 姓名:[[${emp}]]
复制gulimall-test-sso-client模块放到父模块(gulimall)下面=》修改模块名为gulimall-test-sso-client2 =》修改pom文件、application.properties、HelloController、list.html,区分客户端client1。
打开SwitchHosts软件,配置ip和域名映射
1. 启动三个服务;
2. 浏览器访问客户端(client1)http://client1.com:8081/employees,未登录跳转到单点登录服务ssoserver.com的登录页login.html,如下:
3. 浏览器访问客户端(client2)http://client2.com:8082/boss ,未登录跳到单点登录服务ssoserver.com的登录页login.html,如下:
4. 在redirect_url=http://client1.com:8081/employees下登录,如下:
登录成功,如下:
5. 刷新(client2)http://ssoserver.com:8080/login.html?redirect_url=http://client2.com:8082/boss或访问http://client2.com:8082/boss
6. 查看登录服务的登录标识sso_token,如下:
SSO流程:
1. 创建单点登录服务器和客户端
2. 浏览器访问客户端(client1)http://client1.com:8081/employees =》跳转到单点登录服务器 http://ssoserver.com:8080/login.html?redirect_url=http://client1.com:8081/employees
3. 登录页面将带来的redirect_url值放在隐藏的输入框
4. 输入用户名密码,点击登录,登录成功后跳转到redirect_url指定的地址并带上token:
1) 用户名密码正确,将用户信息存放在redis;
2)将登录标识sso_token存放在Cookie中,有这个标识别的客户端无需在登陆;
3)跳转到redirect_url指定的地址并将令牌token返回给客户端http://client1.com:8081/employees?token=uuid
5. 客户端判断是否返回token
1)判断是否返回token(是否登录);
2)根据token去单点登录服务器获取登录用户信息;
3)将从登录服务器获取的用户信息放到自己的session
7. 客户端(client2)无需登录可以直接访问http://client2.com:8082/boss,因为登录服务器Cookie中保存有登录标识。