一、 环境搭建 创建认证模块
pom.xml
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 54 55 56 57 58 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <groupId > com.oy.gulimall</groupId > <artifactId > gulimall-auth-server</artifactId > <version > 0.0.1-SNAPSHOT</version > <name > gulimall-auth-server</name > <description > 认证服务(社交登录、Oauth2.0、单点登录)</description > <properties > <java.version > 1.8</java.version > <project.build.sourceEncoding > UTF-8</project.build.sourceEncoding > <project.reporting.outputEncoding > UTF-8</project.reporting.outputEncoding > <spring-boot.version > 2.1.18.RELEASE</spring-boot.version > <spring-cloud.version > Greenwich.SR6</spring-cloud.version > </properties > <dependencies > <dependency > <groupId > com.oy.gulimall</groupId > <artifactId > gulimall-common</artifactId > <version > 0.0.1-SNAPSHOT</version > <exclusions > <exclusion > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > </exclusion > </exclusions > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-openfeign</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-devtools</artifactId > <scope > runtime</scope > <optional > true</optional > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies > </project >
application.yml
1 2 3 4 5 6 7 8 9 10 11 spring: application: name: gulimall-auth-server cloud: nacos: discovery: server-addr: 192.168 .56 .10 :8848 thymeleaf: cache: false server: port: 20000
bootstrap.properties
1 2 3 4 5 6 7 8 spring.application.name =gulimall-auth-server spring.cloud.nacos.config.server-addr =192.168.56.10:8848 server.port =20000 spring.cloud.nacos.config.namespace =29dda87b-1fde-405c-afd8-c5d79f7c5efe spring.cloud.nacos.config.ext-config[0].data-id =gulimall-auth-server.yml spring.cloud.nacos.config.ext-config[0].group =DEFAULT_GROUP spring.cloud.nacos.config.ext-config[0].refresh =true
主启动类
1 2 3 4 5 6 7 8 9 @EnableFeignClients @EnableDiscoveryClient @SpringBootApplication public class GulimallAuthServerApplication { public static void main (String[] args) { SpringApplication.run(GulimallAuthServerApplication.class, args); } }
启动验证
页面及域名访问初始化
1 2 3 4 5 # gulimall192.168.56.10 gulimall.com 192.168.56.10 search.gulimall.com 192.168.56.10 item.gulimall.com 192.168.56.10 auth.gulimall.com
1 2 3 4 - id: gumall_auth_server uri: lb://gulimall-auth-server predicates: - Host=auth.gulimall.com
引入登录页面
将资料高级篇登录页面和注册页面放到 Nginx 动静分离配置。
二、注册功能 1、验证码倒计时
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 $("#sendCode" ).click (function ( ) { if ($(this ).hasClass ("disabled" )) { } else { timeOutChangeStyle (); var phone = $("#phoneNum" ).val (); $.get ("/sms/sendCode?phone=" + phone, function (data ) { if (data.code != 0 ) { alert (data.msg ); } }); } }); let time = 60 ;function timeOutChangeStyle ( ) { $("#sendCode" ).attr ("class" , "disabled" ); if (time == 0 ) { $("#sendCode" ).text ("点击发送验证码" ); time = 60 ; $("#sendCode" ).attr ("class" , "" ); } else { $("#sendCode" ).text (time + "s后再次发送" ); time--; setTimeout ("timeOutChangeStyle()" , 1000 ); } }
2、 整合短信服务 在阿里云网页购买试用的短信服务
在gulimall-third-party
中编写发送短信组件,其中host
、path
、appcode
可以在配置文件中使用前缀spring.cloud.alicloud.sms
进行配置
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 @Data @ConfigurationProperties(prefix = "spring.cloud.alicloud.sms") @Controller public class SmsComponent { private String host; private String path; private String appcode; public void sendCode (String phone,String code) { String method = "POST" ; Map<String, String> headers = new HashMap <String, String>(); headers.put("Authorization" , "APPCODE " + appcode); Map<String, String> querys = new HashMap <String, String>(); querys.put("mobile" ,phone); querys.put("param" , "code:" +code); querys.put("tpl_id" , "TP1711063" ); Map<String, String> bodys = new HashMap <String, String>(); try { HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys); System.out.println(response.toString()); } catch (Exception e) { e.printStackTrace(); } } }
编写 controller,给别的服务提供远程调用发送验证码的接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Controller @RequestMapping(value = "/sms") public class SmsSendController { @Resource private SmsComponent smsComponent; @ResponseBody @GetMapping(value = "/sendCode") public R sendCode (@RequestParam("phone") String phone, @RequestParam("code") String code) { smsComponent.sendCode(phone,code); System.out.println(phone+code); return R.ok(); } }
(3) 接口防刷 由于发送验证码的接口暴露,为了防止恶意攻击,我们不能随意让接口被调用。
在 redis 中以phone-code
将电话号码和验证码进行存储并将当前时间与 code 一起存储如果调用时以当前phone
取出的 v 不为空且当前时间在存储时间的 60s 以内,说明 60s 内该号码已经调用过,返回错误信息 60s 以后再次调用,需要删除之前存储的phone-code
code 存在一个过期时间,我们设置为 10min,10min 内验证该验证码有效 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @GetMapping("/sms/sendCode") @ResponseBody public R sendCode (@RequestParam("phone") String phone) { ValueOperations<String, String> ops = redisTemplate.opsForValue(); String prePhone = AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone; String v = ops.get(prePhone); if (!StringUtils.isEmpty(v)) { long pre = Long.parseLong(v.split("_" )[1 ]); if (System.currentTimeMillis() - pre < 60000 ) { return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg()); } } redisTemplate.delete(prePhone); String code = String.valueOf((int )((Math.random() + 1 ) * 100000 )); ops.set(prePhone,code+"_" +System.currentTimeMillis(),10 , TimeUnit.MINUTES); thirdPartFeignService.sendCode(phone, code); return R.ok(); }
(4) 注册接口编写 在gulimall-auth-server
服务中编写注册的主体逻辑
若 JSR303 校验未通过,则通过BindingResult
封装错误信息,并重定向至注册页面
若通过 JSR303 校验,则需要从redis
中取值判断验证码是否正确,正确的话通过会员服务注册
会员服务调用成功则重定向至登录页,否则封装远程服务返回的错误信息返回至注册页面
注: RedirectAttributes
可以通过 session 保存信息并在重定向的时候携带过去
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Data public class UserRegisterVo { @NotEmpty(message = "用户名不能为空") @Length(min = 6, max = 19, message = "用户名长度在6-18字符") private String userName; @NotEmpty(message = "密码必须填写") @Length(min = 6,max = 18,message = "密码必须填写6-18位字符") private String password; @NotEmpty(message = "手机号不能为空") @Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确") private String phone; @NotEmpty(message = "验证码不能为空") private String code; }
1 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 @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.gulimall.com/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.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 { errors.put("code" , "验证码错误" ); attributes.addFlashAttribute("errors" , errors); return "redirect:http://auth.gulimall.com/reg.html" ; } } }
通过gulimall-member
会员服务注册逻辑
通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间 1 2 3 4 5 6 7 8 9 10 11 12 13 @RequestMapping("/register") 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(); }
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 public void register (MemberRegisterVo registerVo) { checkPhoneUnique(registerVo.getPhone()); checkUserNameUnique(registerVo.getUserName()); MemberEntity entity = new MemberEntity (); entity.setUsername(registerVo.getUserName()); entity.setMobile(registerVo.getPhone()); entity.setCreateTime(new Date ()); BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder (); String encodePassword = passwordEncoder.encode(registerVo.getPassword()); entity.setPassword(encodePassword); MemberLevelEntity defaultLevel = memberLevelService.getOne(new QueryWrapper <MemberLevelEntity>().eq("default_status" , 1 )); entity.setLevelId(defaultLevel.getId()); this .save(entity); } private void checkUserNameUnique (String userName) { Integer count = baseMapper.selectCount(new QueryWrapper <MemberEntity>().eq("username" , userName)); if (count > 0 ) { throw new UserExistException (); } } private void checkPhoneUnique (String phone) { Integer count = baseMapper.selectCount(new QueryWrapper <MemberEntity>().eq("mobile" , phone)); if (count > 0 ) { throw new PhoneNumExistException (); } }
3. 用户名密码登录 在gulimall-auth-server
模块中的主体逻辑
通过会员服务远程调用登录接口如果调用成功,重定向至首页 如果调用失败,则封装错误信息并携带错误信息重定向至登录页 1 2 3 4 5 6 7 8 9 10 11 12 13 @RequestMapping("/login") 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
模块中完成登录
当数据库中含有以当前登录名为用户名或电话号且密码匹配时,验证通过,返回查询到的实体 否则返回 null,并在 controller 返回用户名或密码错误
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 @RequestMapping("/login") 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 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 ; }
4. 社交登录 (1) oauth2.0 (2) 在微博开放平台创建应用 (3) 在登录页引导用户至授权页 1 2 GET https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI
client_id
: 创建网站应用时的app key
YOUR_REGISTERED_REDIRECT_URI
: 认证完成后的跳转链接(需要和平台高级设置一致)如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE
code 是我们用来换取令牌的参数
(4) 换取 token 1 2 POST 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
: 创建网站应用时的app key
client_secret
: 创建网站应用时的app secret
YOUR_REGISTERED_REDIRECT_URI
: 认证完成后的跳转链接(需要和平台高级设置一致)code
:换取令牌的认证码返回数据如下
(5) 获取用户信息 https://open.weibo.com/wiki/2/users/show
结果返回 json
(6) 代码编写 认证接口
通过HttpUtils
发送请求获取token
,并将token
等信息交给member
服务进行社交登录 若获取token
失败或远程调用服务失败,则封装错误信息重新转回登录页 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 @Controller public class OauthController { @Autowired private MemberFeignService memberFeignService; @RequestMapping("/oauth2.0/weibo/success") public String authorize (String code, RedirectAttributes attributes) throws Exception { Map<String, String> query = new HashMap <>(); query.put("client_id" , "2144***074" ); query.put("client_secret" , "ff63a0d8d5*****29a19492817316ab" ); query.put("grant_type" , "authorization_code" ); query.put("redirect_uri" , "http://auth.gulimall.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" )); MemberResponseVo memberResponseVo = JSON.parseObject(jsonString, new TypeReference <MemberResponseVo>() { }); attributes.addFlashAttribute("user" , memberResponseVo); return "redirect:http://gulimall.com" ; }else { errors.put("msg" , "登录失败,请重试" ); attributes.addFlashAttribute("errors" , errors); return "redirect:http://auth.gulimall.com/login.html" ; } }else { errors.put("msg" , "获得第三方授权失败,请重试" ); attributes.addFlashAttribute("errors" , errors); return "redirect:http://auth.gulimall.com/login.html" ; } }
登录接口
登录包含两种流程,实际上包括了注册和登录 如果之前未使用该社交账号登录,则使用token
调用开放 api 获取社交账号相关信息,注册并将结果返回 如果之前已经使用该社交账号登录,则更新token
并将结果返回 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 @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 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; }
5. SpringSession (1) session 原理 jsessionid
相当于银行卡,存在服务器的session
相当于存储的现金,每次通过jsessionid
取出保存的数据
问题:但是正常情况下session
不可跨域,它有自己的作用范围
(2) 分布式下 session 共享问题 (3) 解决方案 1) session 复制
2) 客户端存储
3) hash 一致性
4) 统一存储
(4) SpringSession 整合 redis 通过SpringSession
修改session
的作用域
1) 环境搭建 导入依赖
https://docs.spring.io/spring-session/docs/2.5.0/reference/html5/#samples
auth 服务、product 服务 pom 文件
1 2 3 4 5 6 7 8 <dependency > <groupId > org.springframework.session</groupId > <artifactId > spring-session-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency >
修改配置
1 2 3 4 5 spring: redis: host: 192.168 .56 .102 session: store-type: redis
添加注解
1 2 @EnableRedisHttpSession public class GulimallAuthServerApplication {
2) 自定义配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Configuration public class GulimallSessionConfig { @Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer () { return new GenericJackson2JsonRedisSerializer (); } @Bean public CookieSerializer cookieSerializer () { DefaultCookieSerializer serializer = new DefaultCookieSerializer (); serializer.setCookieName("GULISESSIONID" ); serializer.setDomainName("gulimall.com" ); return serializer; } }
(5) SpringSession 核心原理 - 装饰者模式 原生的获取session
时是通过HttpServletRequest
获取的 这里对 request 进行包装,并且重写了包装 request 的getSession()
方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { request.setAttribute(SESSION_REPOSITORY_ATTR, this .sessionRepository); SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper ( request, response, this .servletContext); SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper ( wrappedRequest, response); try { filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { wrappedRequest.commitSession(); } }