1. 注册
基本逻辑
- 1)数据格式校验,以及用户名是否已存在
- 2)核对验证码
- 3)MD5加盐对密码加密
- 4)写入数据库,返回登录页
1.1 数据格式校验
通过注解可以给前端传递过来的值进行校验,例如@NotEmpty, @Length等。
但是这个注解必须配合 @Valid 使用,完成对参数的校验。
而校验的结果,也会自动封装到 BindingResult 类型中,通过这个参数可以很方便的对错误的参数进行处理。hasErrors() 可以判断是否有参数校验错误,如果有,可以通过 getFieldsErrors() 方法获取错误列表。
@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;
}
@PostMapping(value = "/register")
public String register(@Valid UserRegisterVo vos, BindingResult result, RedirectAttributes attributes) {
//如果有错误回到注册页面
if (result.hasErrors()) {
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
attributes.addFlashAttribute("errors",errors);
//效验出错回到注册页面
return "redirect:http://auth.gulimall.com/reg.html";
}
......
}
1.2 核对验证码
第一种:阿里云短信验证码
这种方案在发送验证码时将验证码存入redis,再通过短信发送给用户,最后用输入的验证码与redis中的验证码匹配。这种方案需要注意接口防刷,60s内只能发送一次验证码
@ResponseBody
@GetMapping(value = "/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {
//1、接口防刷
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
if (!StringUtils.isEmpty(redisCode)) {
//活动存入redis的时间,用当前时间减去存入redis的时间,判断用户手机号是否在60s内发送验证码
long currentTime = Long.parseLong(redisCode.split("_")[1]);
if (System.currentTimeMillis() - currentTime < 60000) {
//60s内不能再发
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}
//2、验证码的再次效验 redis.存key-phone,value-code
int code = (int) ((Math.random() * 9 + 1) * 100000);
String codeNum = String.valueOf(code);
String redisStorage = codeNum + "_" + System.currentTimeMillis();
//存入redis,防止同一个手机号在60秒内再次发送验证码
stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone,
redisStorage, 10, TimeUnit.MINUTES);
//这里调用短信服务
thirdPartFeignService.sendCode(phone, codeNum);
return R.ok();
}
第二种:验证码的插件(忘了是啥)
1.3 MD5加盐对密码加密
spring提供了MD5加盐对密码加密的方法
// Spring 盐值加密
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
//加密
String encode = bCryptPasswordEncoder.encode("123456");
//判断密码和加密后的密文是否匹配
boolean matches = bCryptPasswordEncoder.matches("123456", "$2a$10$GT0TjB5YK5Vx77Y.2N7hkuYZtYAjZjMlE6NWGE2Aar/7pk/Rmhf8S");
1.4 保存数据
2. 登录
2.1. 有状态登录
为了保证客户端cookie的安全性,服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。
例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的sessionId。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
- 服务端保存大量数据,增加服务端压力
- 分布式环境下session在不同服务器间得同步
2.2. 无状态登录
微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服务的无状态性,即:
- 服务端不保存任何客户信息
- 客户端的每次请求必须自己提交相关身份信息
带来的好处是什么呢?
- 服务端可以任意的迁移和伸缩
- 减小服务端存储压力
无状态登录流程
- 当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
- 认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
- 以后每次请求,客户端都携带认证的token
- 服务的对token进行解密,判断是否有效。
token的安全性
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。一般采用JWT + RSA非对称加密
JWT
JWT包含三部分数据:
-
Header:头部,通常头部有两部分信息:
- token类型:JWT
- 加密方式:base64(HS256)
-
Payload:载荷,就是有效数据,一般包含下面信息:
- 用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
- 注册声明:如token的签发时间,过期时间,签发人等
这部分也会采用base64编码,得到第二部分数据
-
Signature:签名,是整个数据的认证信息。根据前两步的数据,再加上指定的密钥(secret)(不要泄漏,最好周期性更换),通过base64编码生成。用于验证整个数据完整和可靠性
@Service
@EnableConfigurationProperties({JwtProperties.class})
public class AuthService {
@Autowired
private GmallUmsClient umsClient;
@Autowired
private JwtProperties jwtProperties;
public void accredit(String loginName, String password, HttpServletRequest request, HttpServletResponse response) {
try {
// 1. 完成远程请求,获取用户信息
ResponseVo<UserEntity> userEntityResponseVo = this.umsClient.queryUser(loginName, password);
UserEntity userEntity = userEntityResponseVo.getData();
// 2. 判断用户信息是否为空
if (userEntity == null) {
throw new UserException("用户名或者密码有误!");
}
// 3. 把用户id及用户名放入载荷
Map<String, Object> map = new HashMap<>();
map.put("userId", userEntity.getId());
map.put("username", userEntity.getUsername());
// 4. 为了防止jwt被别人盗取,载荷中加入用户ip地址
String ipAddress = IpUtil.getIpAddress(request);
map.put("ip", ipAddress);
// 5. 制作jwt类型的token信息
String token = JwtUtil.generateToken(map, this.jwtProperties.getPrivateKey(), this.jwtProperties.getExpire());
// 6. 把jwt放入cookie中
CookieUtil.setCookie(request, response, this.jwtProperties.getCookieName(), token, this.jwtProperties.getExpire() * 60);
// 7.用户昵称放入cookie中,方便页面展示昵称
CookieUtil.setCookie(request, response, this.jwtProperties.getUnick(), userEntity.getNickname(), this.jwtProperties.getExpire() * 60);
} catch (Exception e) {
e.printStackTrace();
throw new UserException("用户名或者密码出错!");
}
}
}
2.3单点登录
同域下的单点登录
比如我们有个域名叫做:a.com,同时有两个业务系统分别为:app1.a.com和app2.a.com。还需要设立一个登录系统,叫做:sso.a.com。
在sso.a.com中登录,其实是在sso.a.com的服务端的session中记录了登录状态,同时在浏览器端(Browser)的sso.a.com下写入了Cookie。但是仅仅这样是不能让app1.a.com和app2.a.com登录的:
- Cookie是不能跨域的,我们Cookie的domain属性是sso.a.com,在给app1.a.com和app2.a.com发送请求是带不上的。
- sso、app1和app2是不同的应用,它们的session存在自己的应用内,是不共享的。
那么我们如何解决这两个问题呢?
-
sso登录以后,可以将Cookie的域设置为顶域,即.a.com,这样所有子域的系统都可以访问到顶域的Cookie。
-
3个系统的Session共享。共享Session的解决方案有很多,例如:Spring-Session。
不同域下的单点登录
比如我们有个域名叫做:a.com,同时有两个业务系统分别为:app1.a.com和app2.a.com。还需要设立一个登录系统,叫做:sso.a.com。
具体流程如下:
用户访问app1系统
- 用户访问app1系统,发现用户还没有登录,跳转到SSO登录系统。
- 用户在sso系统进行登录,将登录状态写入SSO的session,浏览器的SSO域下写入Cookie。并且生成
一个token,然后跳转到app1系统,同时将token作为参数传递给app1系统。 - app1系统拿到token后,从后台向SSO发送请求,验证ST是否有效。
- 验证通过后,app1系统将登录状态写入session并设置app1域下的Cookie。app1登录完成
用户访问app2系统
- 用户访问app2系统,app2系统没有登录,跳转到SSO。
- 由于SSO已经登录了,不需要重新登录认证。
- SSO生成token,浏览器跳转到app2系统,并将token作为参数传递给app2。
- app2拿到token,后台访问SSO,验证ST是否有效。
- 验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。app2登录完成