笔记-基础篇-1(P1-P28):https://blog.csdn.net/hancoder/article/details/106922139
笔记-基础篇-2(P28-P100):https://blog.csdn.net/hancoder/article/details/107612619
笔记-高级篇(P340):https://blog.csdn.net/hancoder/article/details/107612746
笔记-vue:https://blog.csdn.net/hancoder/article/details/107007605
笔记-elastic search、上架、检索:https://blog.csdn.net/hancoder/article/details/113922398
笔记-认证服务:https://blog.csdn.net/hancoder/article/details/114242184
笔记-分布式锁与缓存:https://blog.csdn.net/hancoder/article/details/114004280
笔记-集群篇:https://blog.csdn.net/hancoder/article/details/107612802
springcloud笔记:https://blog.csdn.net/hancoder/article/details/109063671
笔记版本说明:2020年提供过笔记文档,但只有P1-P50的内容,2021年整理了P340的内容。请点击标题下面分栏查看系列笔记
声明:
sql:https://github.com/FermHan/gulimall/sql文件
本项目其他笔记见专栏:https://blog.csdn.net/hancoder/category_10822407.html
本篇9K字,请直接ctrl+F搜索内容
创建gulimall-auth-server
微服务,导入依赖,引入login.html
和reg.html
,并把静态资源放到nginx的static目录下,修改hosts 192.168.56.10 auth.gulimall.com
网关配置启动起来即可
登录:http://auth.gulimall.com/
注册:http://auth.gulimall.com/reg.html
主页:http://gulimall.com/
注册页面controller
//如果controller只是跳转视图功能,可以直接注入controller
@Configuration
public class MyWebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/reg.html").setViewName("reg");
}
}
点击获取验证码后,进入倒计时
$("#sendCode").attr("class", "disabled")
$(function () {
$("#sendCode").click(function () {
if ($(this).hasClass("disabled")) {
// 1.进入倒计时效果
} else {
$.get("/sms/sendcode?phone=" + $("#phoneNum").val(), function (data) {
if (data.code != 0) {
layer.msg(data.msg)
}
});
// 2.给指定手机号发送验证码
timeoutChangeStyle()
}
})
})
// 外部变量计时
let num = 60;
function timeoutChangeStyle() {
$("#sendCode").attr("class", "disabled")
if (num == 0) {
//可以再次发送
num = 60;
$("#sendCode").attr("class", "");//取消disabled
$("#sendCode").text("发送验证码");
} else {
var str = num + "s 后再次发送";
$("#sendCode").text(str);
// 1s后回调
setTimeout("timeoutChangeStyle()", 1000);
}
num--
}
短信发送的controller:
@ResponseBody
@GetMapping("/sms/snedcode")
public R sendCode(@RequestParam("phone") String phone){
用短息服务给手机发送指定的内容,
而该内容在redis中保存一份,
所以带着验证码来的时候可以验证匹配到
具体代码后面再写
https://market.aliyun.com/products/?keywords=短信
购买页面下有请求的url,点击去调试测试
请求参数:
名称 | 类型 | 是否必须 | 描述 |
---|---|---|---|
content | STRING | 必选 | 模板中变量名与参数值,多项值以","分隔 |
phone_number | STRING | 必选 | 手机号码 |
template_id | STRING | 必选 | 模板ID |
一般来说,就是html发送给java,java再发送给短信服务商。用户接收到验证码后,发送过来填写的验证码,进行验证。
逻辑:
电话+验证码
,调用第三方服务在gulimall-third-party
中编写发送短信组件,其中host
、path
、appcode
可以在配置文件中使用前缀spring.cloud.alicloud.sms
进行配置
@Data
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Component
public class SmsComponent {
private String host;
private String path;
private String skin;
private String sign;
private String appCode;
public String sendSmsCode(String phone, String code){
String method = "GET";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + this.appCode);
Map<String, String> querys = new HashMap<String, String>();
querys.put("code", code);
querys.put("phone", phone);
querys.put("skin", this.skin);
querys.put("sign", this.sign);
HttpResponse response = null;
try {
response = HttpUtils.doGet(this.host, this.path, method, headers, querys);
//获取response的body
if(response.getStatusLine().getStatusCode() == 200){
return EntityUtils.toString(response.getEntity());
}
} catch (Exception e) {
e.printStackTrace();
}
return "fail_" + response.getStatusLine().getStatusCode();
}
}
编写controller,给别的服务提供远程调用发送验证码的接口
@Controller
@RequestMapping("/sms")
public class SmsSendController {
@Autowired
private SmsComponent smsComponent;
/*** 提供给别的服务进行调用的
该controller是发给短信服务的,不是验证的
*/
@GetMapping("/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code){
if(!"fail".equals(smsComponent.sendSmsCode(phone, code).split("_")[0])){
return R.ok();
}
return R.error(BizCodeEnum.SMS_SEND_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_SEND_CODE_EXCEPTION.getMsg());
}
}
短信服务编写好后,我们在认证微服务中远程调用。
为了防止恶意攻击短信接口,用redis缓存电话号
phone-code
为前缀将电话号码和验证码进行存储并将当前时间与code一起存储
phone
取出的v不为空且当前时间在存储时间的60s以内,说明60s内该号码已经调用过,返回错误信息phone-code
@ResponseBody
@GetMapping("/sms/snedcode")
public R sendCode(@RequestParam("phone") String phone){
// 接口防刷,redis缓存 sms:code:电话号
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
// 如果不为空,返回错误信息
if(null != redisCode && redisCode.length() > 0){
long CuuTime = Long.parseLong(redisCode.split("_")[1]);
if(System.currentTimeMillis() - CuuTime < 60 * 1000){
// 60s
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}
// 生成验证码
String code = UUID.randomUUID().toString().substring(0, 6);
String redis_code = code + "_" + System.currentTimeMillis();
// 缓存验证码
stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, redis_code , 10, TimeUnit.MINUTES);
try {
// 调用第三方短信服务
return thirdPartFeignService.sendCode(phone, code);
} catch (Exception e) {
log.warn("远程调用不知名错误 [无需解决]");
}
return R.ok();
}
前端也可以进行校验,此处是后端的验证
@Data
public class UserRegisterVo {
// JSR303校验
@Length(min = 6,max = 20,message = "用户名长度必须在6-20之间")
@NotEmpty(message = "用户名必须提交")
private String userName;
@Length(min = 6,max = 20,message = "用户名长度必须在6-20之间")
@NotEmpty(message = "密码必须提交")
private String password;
@NotEmpty(message = "手机号不能为空")
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$", message = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String code;
}
前面的JSR303校验怎么用:
JSR303校验的结果,被封装到BindingResult
,再结合BindingResult.getFieldErrors()
方法获取错误信息,有错误就重定向至注册页面
@PostMapping("/register")
public String register(@Valid UserRegisterVo registerVo,
BindingResult result,
RedirectAttributes attributes) {
if (result.hasErrors()){
return "redirect:http://auth.gulimall.com/reg.html";
@ExceptionHandler @ControllerAdvice
在gulimall-auth-server
服务中编写注册的主体逻辑
redis
中确认手机验证码是否正确,一致则删除验证码,(令牌机制)RedirectAttributes
参数转发
注: RedirectAttributes
可以通过session保存信息并在重定向的时候携带过去
@PostMapping("/register") // auth服务
public String register(@Valid UserRegisterVo registerVo, // 注册信息
BindingResult result,
RedirectAttributes attributes) {
//1.判断校验是否通过
Map<String, String> errors = new HashMap<>();
if (result.hasErrors()){
//1.1 如果校验不通过,则封装校验结果
result.getFieldErrors().forEach(item->{
// 获取错误的属性名和错误信息
errors.put(item.getField(), item.getDefaultMessage());
//1.2 将错误信息封装到session中
attributes.addFlashAttribute("errors", errors);
});
//1.2 重定向到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}else {
//2.若JSR303校验通过
//判断验证码是否正确
String code = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone());
//2.1 如果对应手机的验证码不为空且与提交的相等-》验证码正确
if (!StringUtils.isEmpty(code) && registerVo.getCode().equals(code.split("_")[0])) {
//2.1.1 使得验证后的验证码失效
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone());
//2.1.2 远程调用会员服务注册
R r = memberFeignService.register(registerVo);
if (r.getCode() == 0) {
//调用成功,重定向登录页
return "redirect:http://auth.gulimall.com/login.html";
}else {
//调用失败,返回注册页并显示错误信息
String msg = (String) r.get("msg");
errors.put("msg", msg);
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
//2.2 验证码错误
errors.put("code", "验证码错误");
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}
}
验证短信验证码通过,下面开始去数据库保存
通过gulimall-member
会员服务注册逻辑
@RequestMapping("/register") // member
public R register(@RequestBody MemberRegisterVo registerVo) {
try {
memberService.register(registerVo);
//异常机制:通过捕获对应的自定义异常判断出现何种错误并封装错误信息
} catch (UserExistException userException) {
//用户已存在
return R.error(BizCodeEnum.USER_EXIST_EXCEPTION.getCode(), BizCodeEnum.USER_EXIST_EXCEPTION.getMsg());
} catch (PhoneNumExistException phoneException) {
// 手机已经注册
return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(), BizCodeEnum.PHONE_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
@Override // service
public void register(UserRegisterVo userRegisterVo) throws PhoneExistException, UserNameExistException {
MemberEntity entity = new MemberEntity();
// 设置默认等级
MemberLevelEntity memberLevelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(memberLevelEntity.getId());
// 检查手机号 用户名是否唯一
checkPhone(userRegisterVo.getPhone());
checkUserName(userRegisterVo.getUserName());
entity.setMobile(userRegisterVo.getPhone());
entity.setUsername(userRegisterVo.getUserName());
// 密码要加密存储
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
entity.setPassword(bCryptPasswordEncoder.encode(userRegisterVo.getPassword()));
// 其他的默认信息
entity.setCity("湖南 长沙");
entity.setCreateTime(new Date());
entity.setStatus(0);
entity.setNickname(userRegisterVo.getUserName());
entity.setBirth(new Date());
entity.setEmail("[email protected]");
entity.setGender(1);
entity.setJob("JAVA");
baseMapper.insert(entity);
}
@Override // void 无需bool // 自定义异常继承 extends RuntimeException
public void checkPhone(String phone) throws PhoneExistException{
if(this.baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone)) > 0){
throw new PhoneExistException();
}
}
public class PhoneExistException extends RuntimeException {
public PhoneExistException() {
super("手机号存在");
}
}
public class PhoneExistException extends RuntimeException{
public PhoneExistException(){
super("手机号已注册");
}
}
java密码安全可以参考我之前的笔记:https://blog.csdn.net/hancoder/article/details/111464250
本文采样md5信息加密算法,但其实他不安全,可以加盐提高安全性Md5Crypt.md5Crypt(bytes,salt)
spring有个加密的BCryptPasswordEncoder.match()
在gulimall-auth-server
模块中的主体逻辑
@RequestMapping("/login") // auth
public String login(UserLoginVo vo,RedirectAttributes attributes){
// 远程服务
R r = memberFeignService.login(vo);
if (r.getCode() == 0) {
return "redirect:http://gulimall.com/";
}else {
// 登录失败重回登录页面,携带错误信息
String msg = (String) r.get("msg");
Map<String, String> errors = new HashMap<>();
errors.put("msg", msg);
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
在gulimall-member
模块中完成登录
用户名或密码错误
@RequestMapping("/login") // member
public R login(@RequestBody MemberLoginVo loginVo) {
MemberEntity entity=memberService.login(loginVo);
if (entity!=null){
return R.ok();
}else {
return R.error(BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getCode(), BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getMsg());
}
}
@Override // service
public MemberEntity login(MemberLoginVo loginVo) {
String loginAccount = loginVo.getLoginAccount();
//以用户名或电话号登录的进行查询
MemberEntity entity = this.getOne(new QueryWrapper<MemberEntity>().eq("username", loginAccount).or().eq("mobile", loginAccount));
if (entity!=null){
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
boolean matches = bCryptPasswordEncoder.matches(loginVo.getPassword(), entity.getPassword());
if (matches){
entity.setPassword("");
return entity;
}
}
return null;
}
社交登录指的是用QQ微信等方式登录
上面社交登录的流程就是OAuth协议
OAuth(开放授权)是一个开放标准,允许用户授权第三方移动应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容,OAuth2.0是OAuth协议的延续版本,但不向后兼容OAuth 1.0即完全废止了OAuth1.0。
微信:https://developers.weixin.qq.com/doc/oplatform/Mobile_App/WeChat_Login/Development_Guide.html
客户端是
资源拥有者:用户本人
授权服务器:QQ服务器,微信服务器等。返回访问令牌
资源服务器:拿着令牌访问资源服务器看令牌合法性
1、使用Code换取AccessToken,Code只能用一次
2、同一个用户的accessToken一段时间是不会变化的,即使多次获取
https://open.weibo.com/authentication
https://open.weibo.com/connect 点击网站接入
填写一些个人信息后,https://open.weibo.com/apps/new?sort=web 创建新应用gulimallxxx,会得到APP KEY
和APP Secret
在高级信息里填写
授权回调页
:gulimall.com/success取消授权回调页
:gulimall.com/failhttps://open.weibo.com/wiki/授权机制说明 查看OAuth2
\1. 引导需要授权的用户到如下地址:
https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=授权后跳转的uri
示例:
https://api.weibo.com/oauth2/authorize?
client_id=刚才申请的APP-KEY &
response_type=code&
redirect_uri=http://gulimall.com/success
\2. 如果用户同意授权(输入账号密码),带着code,页面跳转至 gulimall.com/success/?code=CODE
跳回我们网站的时候,带了一个code码,这个code码可以理解为用户登录的sessionID
\3. POST拿着code码换取Access Token
https://api.weibo.com/oauth2/access_token?
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
grant_type=authorization_code&
redirect_uri=YOUR_REGISTERED_REDIRECT_URI&
code=CODE
其中client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET可以使用basic方式加入header中,返回值
{
"access_token": "SlAV32hkKG",
"remind_in": 3600, # 也是声明周期,但将废弃
"expires_in": 3600 # access_token的生命周期;
}
\4. 使用获得的Access Token调用API,可以获取头像等信息 https://open.weibo.com/wiki/2/users/show
结果返回json
注意点:
HttpUtils
发送请求获取token
,并将token
等信息交给member
服务进行社交登录
token
失败或远程调用服务失败,则封装错误信息重新转回登录页登录成功跳转到首页,但是怎么保证没有验证情况下访问不了首页:用shiro等拦截器功能
@GetMapping("/weibo/success") // Oath2Controller
public String weiBo(@RequestParam("code") String code, HttpSession session) throws Exception {
// 根据code换取 Access Token
Map<String,String> map = new HashMap<>();
map.put("client_id", "1294828100");
map.put("client_secret", "a8e8900e15fba6077591cdfa3105af44");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
Map<String, String> headers = new HashMap<>();
// 去获取token
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, null, map);
if(response.getStatusLine().getStatusCode() == 200){
// 获取响应体: Access Token
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
// 相当于我们知道了当前是那个用户
// 1.如果用户是第一次进来 自动注册进来(为当前社交用户生成一个会员信息 以后这个账户就会关联这个账号)
R login = memberFeignService.login(socialUser);
if(login.getCode() == 0){
MemberRsepVo rsepVo = login.getData("data" ,new TypeReference<MemberRsepVo>() {
});
log.info("\n欢迎 [" + rsepVo.getUsername() + "] 使用社交账号登录");
// 第一次使用session 命令浏览器保存这个用户信息 JESSIONSEID 每次只要访问这个网站就会带上这个cookie
// 在发卡的时候扩大session作用域 (指定域名为父域名)
// TODO 1.默认发的当前域的session (需要解决子域session共享问题)
// TODO 2.使用JSON的方式序列化到redis
// new Cookie("JSESSIONID","").setDomain("gulimall.com");
session.setAttribute(AuthServerConstant.LOGIN_USER, rsepVo);
// 登录成功 跳回首页
return "redirect:http://gulimall.com";
}else{
return "redirect:http://auth.gulimall.com/login.html";
}
}else{
return "redirect:http://auth.gulimall.com/login.html";
}
}
token
调用开放api获取社交账号相关信息(头像等),注册并将结果返回token
并将结果返回@RequestMapping("/oauth2/login")
public R login(@RequestBody SocialUser socialUser) {
MemberEntity entity=memberService.login(socialUser);
if (entity!=null){
return R.ok().put("memberEntity",entity);
}else {
return R.error();
}
}
@Override // 已经用code生成了token
public MemberEntity login(SocialUser socialUser) {
// 微博的uid
String uid = socialUser.getUid();
// 1.判断社交用户登录过系统
MemberDao dao = this.baseMapper;
MemberEntity entity = dao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
MemberEntity memberEntity = new MemberEntity();
if(entity != null){
// 注册过
// 说明这个用户注册过, 修改它的资料
// 更新令牌
memberEntity.setId(entity.getId());
memberEntity.setAccessToken(socialUser.getAccessToken());
memberEntity.setExpiresIn(socialUser.getExpiresIn());
// 更新
dao.updateById(memberEntity);
entity.setAccessToken(socialUser.getAccessToken());
entity.setExpiresIn(socialUser.getExpiresIn());
entity.setPassword(null);
return entity;
}else{
// 没有注册过
// 2. 没有查到当前社交用户对应的记录 我们就需要注册一个
HashMap<String, String> map = new HashMap<>();
map.put("access_token", socialUser.getAccessToken());
map.put("uid", socialUser.getUid());
try {
// 3. 查询当前社交用户账号信息(昵称、性别、头像等)
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), map);
if(response.getStatusLine().getStatusCode() == 200){
// 查询成功
String json = EntityUtils.toString(response.getEntity());
// 这个JSON对象什么样的数据都可以直接获取
JSONObject jsonObject = JSON.parseObject(json);
memberEntity.setNickname(jsonObject.getString("name"));
memberEntity.setUsername(jsonObject.getString("name"));
memberEntity.setGender("m".equals(jsonObject.getString("gender"))?1:0);
memberEntity.setCity(jsonObject.getString("location"));
memberEntity.setJob("自媒体");
memberEntity.setEmail(jsonObject.getString("email"));
}
} catch (Exception e) {
log.warn("社交登录时远程调用出错 [尝试修复]");
}
memberEntity.setStatus(0);
memberEntity.setCreateTime(new Date());
memberEntity.setBirth(new Date());
memberEntity.setLevelId(1L);
memberEntity.setSocialUid(socialUser.getUid());
memberEntity.setAccessToken(socialUser.getAccessToken());
memberEntity.setExpiresIn(socialUser.getExpiresIn());
// 注册 -- 登录成功
dao.insert(memberEntity);
memberEntity.setPassword(null);
return memberEntity;
}
}
session存储在服务端,jsessionId存在客户端,每次通过jsessionid
取出保存的数据
问题:但是正常情况下session
不可跨域,它有自己的作用范围
这个session被sessionManager管理着
JsessionId列 | 说明 |
---|---|
Value | XXXXXX… |
Domain | gulimall.com要放大域名作用域 |
Path | / |
Expires/Max-Age | 40 |
session要能在不同服务和同服务的集群的共享
用户登录后得到session后,服务把session也复制到别的机器上,显然这种处理很不好
根据用户,到指定的机器上登录。但是远程调用还是不好解决
最终的选择方案,把session放到redis中
https://spring.io/projects/spring-session-data-redis
https://docs.spring.io/spring-session/docs/2.4.2/reference/html5/#modules
通过SpringSession
修改session
的作用域
会员服务、订单服务、商品服务,都是去redis里存储session
Oauth导入依赖
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
修改配置
spring.session.store-type=redis
server.servlet.session.timeout=30m
spring.redis.host=192.168.56.10
添加注解
@EnableRedisHttpSession //创建了一个springSessionRepositoryFilter ,负责将原生HttpSession 替换为Spring Session的实现
public class GulimallAuthServerApplication {
但是现在还有一些问题:
由于默认使用jdk进行序列化,通过导入RedisSerializer
修改为json序列化
并且通过修改CookieSerializer
扩大session
的作用域至**.gulimall.com
@Configuration
public class GulimallSessionConfig {
@Bean // redis的json序列化
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
@Bean // cookie
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("GULISESSIONID"); // cookie的键
serializer.setDomainName("gulimall.com"); // 扩大session作用域,也就是cookie的有效域
return serializer;
}
}
把这个配置放到每个微服务下
网上百度一下:https://blog.csdn.net/m0_46539364/article/details/110533408
就是分析@EnableRedisHttpSession,
@Import({
RedisHttpSessionConfiguration.class})
@Configuration( proxyBeanMethods = false)
public @interface EnableRedisHttpSession {
public class RedisHttpSessionConfiguration
extends SpringHttpSessionConfiguration // 继承
implements 。。。{
// 后面SessionRepositoryFilter会构造时候自动注入他
@Bean // 操作session的方法,如getSession() deleteById()
public RedisIndexedSessionRepository sessionRepository() {
SessionRepositoryFilter,每个请求都要经过该filter
public class SpringHttpSessionConfiguration
implements ApplicationContextAware {
@Bean
public SessionRepositoryFilter extends Session> springSessionRepositoryFilter(SessionRepository sessionRepository) { // 注入前面的bean
SessionRepositoryFilter sessionRepositoryFilter = new SessionRepositoryFilter(sessionRepository);
sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
return sessionRepositoryFilter;
}
前面我们@Bean注入了sessionRepositoryFilter,他是一个过滤器,那我们需要知道他过滤做了什么事情:
session
时是通过HttpServletRequest
获取的getSession()
方法@Override // SessionRepositoryFilter.java
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
//对原生的request、response进行包装
// SessionRepositoryRequestWrapper.getSession()
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
}
finally {
wrappedRequest.commitSession();
}
}
绣花前面的代码,controller层加参数HttpSession,直接session.setAttribute(“user”,user)即可
前端页面的显示可以用
@GetMapping({
"/login.html","/","/index","/index.html"}) // auth
public String loginPage(HttpSession session){
// 从会话从获取loginUser
Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);// "loginUser";
System.out.println("attribute:"+attribute);
if(attribute == null){
return "login";
}
System.out.println("已登陆过,重定向到首页");
return "redirect:http://gulimall.com";
}
@PostMapping("/login") // auth
public String login(UserLoginVo userLoginVo,
RedirectAttributes redirectAttributes,
HttpSession session){
// 远程登录
R r = memberFeignService.login(userLoginVo);
if(r.getCode() == 0){
// 登录成功
MemberRespVo respVo = r.getData("data", new TypeReference<MemberRespVo>() {
});
// 放入session // key为loginUser
session.setAttribute(AuthServerConstant.LOGIN_USER, respVo);//loginUser
log.info("\n欢迎 [" + respVo.getUsername() + "] 登录");
// 登录成功重定向到首页
return "redirect:http://gulimall.com";
}else {
HashMap<String, String> error = new HashMap<>();
// 获取错误信息
error.put("msg", r.getData("msg",new TypeReference<String>(){
}));
redirectAttributes.addFlashAttribute("errors", error);
return "redirect:http://auth.gulimall.com/login.html";
}
}
登录url:http://auth.gulimall.com/login.html
(注意是url,不是页面。)
判断session
中是否有user对象
额外说明:
问题1:我们有sessionId不就可以了吗?为什么还要在session中放到User对象?
为了其他服务可以根据这个user查数据库,只有session的话不能再次找到登录session的用户
问题2:threadlocal的作用?
他是为了放到当前session的线程里,threadlocal就是这个作用,随着线程创建和消亡。把threadlocal定义为static的,这样当前会话的线程中任何代码地方都可以获取到。如果只是在session中的话,一是每次还得去redis查询,二是去调用service还得传入session参数,多麻烦啊
问题3:cookie怎么回事?不是在config中定义了cookie的key和序列化器?
序列化器没什么好讲的,就是为了易读和来回转换。而cookie的key其实是无所谓的,只要两个项目里的key相同,然后访问同一个域名都带着该cookie即可。
上面解决了同域名的session问题,但如果taobao.com
和tianmao.com
这种不同的域名也想共享session呢?
去百度了解下:https://www.jianshu.com/p/75edcc05acfd
最终解决方案:都去中央认证器
spring session已经解决不了不同域名的问题了。无法扩大域名
记住一个核心思想:建议一个公共的登陆点server,他登录了代表这个集团的产品就登录过了
上图是CAS官网上的标准流程,具体流程如下:有两个子系统
app1
、app2
- 用户访问
app1
系统,app1
系统是需要登录的,但用户现在没有登录。- 跳转到CAS server,即SSO登录系统,以后图中的CAS Server我们统一叫做SSO系统。 SSO系统也没有登录,弹出用户登录页。
- 用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session,浏览器(Browser)中写入SSO域下的Cookie。
- SSO系统登录完成后会生成一个
ST
(Service Ticket
),然后跳转到app1
系统,同时将ST作为参数传递给app1系统。app1
系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。- 验证通过后,app系统将登录状态写入session并设置app域下的Cookie。
至此,跨域单点登录就完成了。以后我们再访问app系统时,app就是登录的。接下来,我们再看看访问app2系统时的流程。
- 用户访问
app2
系统,app2系统没有登录,跳转到SSO。- 由于SSO已经登录了,不需要重新登录认证。
- SSO生成ST,浏览器跳转到
app2
系统,并将ST作为参数传递给app2
。app2
拿到ST,后台访问SSO,验证ST是否有效。- 验证成功后,
app2
将登录状态写入session,并在app2
域下写入Cookie。这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。
SSO系统登录后,跳回原业务系统时,带了个参数ST,业务系统还要拿ST再次访问SSO进行验证,觉得这个步骤有点多余。如果想SSO登录认证通过后,通过回调地址将用户信息返回给原业务系统,原业务系统直接设置登录状态,这样流程简单,也完成了登录,不是很好吗?
其实这样问题时很严重的,如果我在SSO没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。
SSO-Single Sign On
3个系统即使域名不一样,想办法给三个系统同步同一个用户的票据;
访问服务:SSO客户端发送请求访问应用系统提供的服务资源。
定向认证:SSO客户端会重定向用户请求到SSO服务器。
用户认证:用户身份认证。
发放票据:SSO服务器会产生一个随机的Service Ticket。
验证票据:SSO服务器验证票据Service Ticket的合法性,验证通过后,允许客户端访问服务。
传输用户信息:SSO服务器验证票据通过后,传输用户认证结果信息给客户端。
单点退出:用户退出单点登录。
先看一下开源sso的项目:https://gitee.com/xuxueli0323/xxl-sso
修改HOSTS:127.0.0.1 ssoserver.com+client1.com+client2.com
# 根项目下
mvn clean package -Dmaven.skip.test=true
# 打包生成了server和client包
# 启动server和client
#server8080 cient1:web-sample8081 cient2:web-sample8082
# 让client12登录一次即可
java -jar server.jar # 8080
java -jar client.jar
# 启动多个web-sample模拟多个微服务
把core项目mvc install 。启动server
8081/employees
请求,判断没登录就跳转到server.com:8080/login.html
登录页,并带上现url
还得得补充一句,老师课上讲得把票据放到controller里太不合适了,你最起码得放到filter或拦截器里
client1.com 8081 和 client2.com 8082 都跳转到ssoserver 8080
<body>
<form action="/employee" method="get">
<input type="text" name="username" value="test">
<button type="submit">查询button>
form>
body>
@GetMapping(value = "/employees") // a系统
public String employees(Model model,
HttpSession session,
@RequestParam(value = "redisKey", required = false) String redisKey) {
// 有loginToken这个参数,代表去过server端登录过了,server端里在redis里保存了个对象,而key:uuid给你发过来了
// 有loginToken这个参数的话代表是从登录页跳回来的,而不是系统a直接传过来的
// 你再拿着uuid再去查一遍user object,返回后设置到当前的系统session里
// 提个问题:为什么当时不直接返回user对象,而是只返回个uuid?其实也可以,但是参数的地方也得是required = false。可能也有一些安全问题
if (!StringUtils.isEmpty(redisKey)) {
// 这个逻辑应该写到过滤器或拦截器里
RestTemplate restTemplate=new RestTemplate();
// 拿着token去服务器,在服务端从redis中查出来他的username
ResponseEntity<Object> forEntity =
restTemplate.getForEntity("http://ssoserver.com:8080/userInfo?redisKey="+ redisKey, Object.class);
Object loginUser = forEntity.getBody();
// 设置到自己的session中
session.setAttribute("loginUser", loginUser);
}
// session里有就代表登录过 // 获得user
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null) {
// 又没有loginToken,session里又没有object,去登录页登录
return "redirect:" + "http://ssoserver.com:8080/login.html"
+ "?url=http://clientA.com/employees";
} else {
// 登录过,执行正常的业务
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps", emps);
return "employees";
}
}
login.html
这个请求,
<body>
<form action="/doLogin" method="post">
<input type="hidden" name="url" th:value="${url}">
用户名:<input name="username" value="test"><br/>
密码:<input name="password" type="password" value="test">
<input type="submit" value="登录">
form>
body>
当点击登录之后,server端返回一个cookie,子系统重新返回去重新请去业务。于是又来server端验证,这回server端有cookie了,该cookie里有用户在redis中的key,重定向时把key带到url后面,子系统就知道怎么找用户信息了
@Controller
public class LoginController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@ResponseBody
@GetMapping("/userInfo") // 得到redis中的存储过的user信息,返回给子系统的session中
public Object userInfo(@RequestParam("redisKey") String redisKey){
// 拿着其他域名转发过来的token去redis里查
Object loginUser = stringRedisTemplate.opsForValue().get(redisKey);
return loginUser;
}
@GetMapping("/login.html") // 子系统都来这
public String loginPage(@RequestParam("url") String url,
Model model,
@CookieValue(value = "redisKey", required = false) String redisKey) {
// 非空代表就登录过了
if (!StringUtils.isEmpty(redisKey)) {
// 告诉子系统他的redisKey,拿着该token就可以查redis了
return "redirect:" + url + "?redisKey=" + redisKey;
}
model.addAttribute("url", url);
// 子系统都没登录过才去登录页
return "login";
}
@PostMapping("/doLogin") // server端统一认证
public String doLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
HttpServletResponse response,
@RequestParam(value="url",required = false) String url){
// 确认用户后,生成cookie、redis中存储 // if内代表取查完数据库了
if(!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){
//简单认为登录正确
// 登录成功跳转 跳回之前的页面
String redisKey = UUID.randomUUID().toString().replace("-", "");
// 存储cookie, 是在server.com域名下存
Cookie cookie = new Cookie("redisKey", redisKey);
response.addCookie(cookie);
// redis中存储
stringRedisTemplate.opsForValue().set(redisKey, username+password+"...", 30, TimeUnit.MINUTES);
// user中存储的url 重定向时候带着token
return "redirect:" + url + "?redisKey=" + redisKey;
}
// 登录失败
return "login";
}
}
因为订单系统必然涉及到用户信息,因此进入订单系统的请求必须是已经登录的,所以我们需要通过拦截器对未登录订单请求进行拦截
WebMvcConfigurer接口.addInterceptor()
方法@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberRespVo> threadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
// 这个请求直接放行
boolean match = new AntPathMatcher().match("/order/order/status/**", uri);
if(match){
return true;
}
// 获取session
HttpSession session = request.getSession();
// 获取登录用户
MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if(memberRespVo != null){
threadLocal.set(memberRespVo);
return true;
}else{
// 没登陆就去登录
session.setAttribute("msg", AuthServerConstant.NOT_LOGIN);
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**");
}
}
加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
在auth-server中登录成功后会把会话设置到session中
MemberRespVo data = login.getData("data",new TypeReference<MemberRespVo>);
session.setAttribute(AuthServerConstant.LOGIN_USER,data);
因为购物车允许临时用户,所以自定义购物车拦截器
而登录操作在其他服务页面里完成即可。也可以重定向解决
具体代码去购物车博文里找
离线笔记均为markdown格式,图片也是云图,10多篇笔记20W字,压缩包仅500k,推荐使用typora阅读。也可以自己导入有道云笔记等软件中