用户登录
概述
单一服务器模式:用户认证
缺点:单点性能压力,无法扩展
SSO(single sign on)模式(单点登录)
适用于分布式:
优点:用户身份信息独立管理,更好的分布式管理。
可以自己扩展安全策略
缺点:认证服务器访问压力较大
Token模式
用户访问业务必须登录的过程
- 检查cookie中是否有token,有则验证成功则可以访问业务
- 若不存在token,重定向到认证中心提示用户登录或注册
- 用户登录成功后带token跳转到原Web应用上,并将token写入到cookie中
- 用户继续访问其他业务时,同样检查cookie过程同上
Token是实现单点登录最常见的机制,其具体过程如下 - 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
- 客户端收到 Token 以后可以把它存储起来,比如放在cookie中
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
- 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就放行
优点:
token支持跨域、且无状态
基于标准化,你的API可以采用标准化的JSON WEB TOKEN
这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *。
使用JWT进行跨域身份认证
JWT概述
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC
7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
传统用户登录模式
Internet服务无法与用户身份验证分开。一般过程如下:
- 用户向服务器发送用户名和密码。
- 验证服务器后,相关数据(如用户角色,登录时间等)将保存在当前会话中。
- 服务器向用户返回session_id,session信息都会写入到用户的Cookie。
- 用户的每个后续请求都将通过在Cookie中取出session_id传给服务器。
- 服务器收到session_id并对比之前保存的数据,确认用户的身份。
这种模式最大的问题是因为session不支持跨域 ,没有分布式架构,无法支持横向扩展。
基于session认证所显露的问题
Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
JWT令牌
JWT的组成
该对象为一个很长的字符串,字符之间通过"."分隔符分为三个子串。
JWT头header
、有效载荷Clalms
、签名哈希Signature
使用Base64 URL
算法将上述JSON对象转换为字符串保存。
JWT头
JWT头部分是一个描述JWT元数据的JSON对象,通常如下所示。
alg
属性表示签名使用的算法 HS256
.
typ
属性表示令牌的类型,JWT令牌统一写为JWT
。
{
"alg": "HS256",
"typ": "JWT"
}
有效载荷
iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
签名哈希
签名哈希部分是对上面两部分数据签名
,通过指定的算法生成哈希,以确保数据不会被篡改。
首先,需要指定一个密码(secret)
。该密码仅仅为保存在服务器
中,并且不能向用户公开。然后,使用标头中指定的签名算法
(默认情况下为HMAC SHA256)根据以下公式生成签名
1
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(claims), secret)
Base64URL算法
如前所述,JWT头和有效载荷序列化的算法都用到了Base64URL。该算法和常见Base64算法类似,稍有差别。
作为令牌的JWT可以放在URL中(例如api.example/?token=xxx)。 Base64中用的三个字符是"+","/“和”=",由于在URL中有特殊含义,因此Base64URL中对他们做了替换:"=“去掉,”+“用”-“替换,”/“用”_"替换,这就是Base64URL算法。
JWT的用法
客户端接收服务器返回的JWT,将其存储在Cookie或LocalStorage中。
- 存储在客户端,不占用服务端的内存资源
- JWT本身包含认证信息,token是经过base64编码,所以可以解码,因此token加密前的对象不应该包含敏感信息,一旦信息泄露,任何人都可以获得令牌的所有权限。为了减少盗用,JWT的有效期不宜设置太长。对于某些重要操作,用户在使用时应该每次都进行进行身份验证。
整合JWT令牌
JWT工具类
1、传入id和name构建Token字符串getJwtToken(String id, String nickname)
2、 传入token判断 ,判断token是否存在与有效checkToken(String jwtToken)
如果token不为空且解密成功则说明token有效
3、 传入http请求,判断token是否存在与有效checkToken(HttpServletRequest request)
1、 从http请求头中获取token信息,并且判断token是否为空
2、 利用加密密钥进行解密,如果解密成功则该token存在且有效
4、根据http请求中token获取会员idgetMemberIdByJwtToken(HttpServletRequest request)
完整代码
package com.guli.common.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* @author helen
* @since 2019/10/16
* jwt的工具类
*/
public class JwtUtils {
//设置token的过期时间
public static final long EXPIRE = 1000 * 60 * 60 * 24;
// 加密密钥-用于字符加密
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
//传入用户id 和用户昵称,生成token字符串,
public static String getJwtToken(String id, String nickname){
//builder构建jwt字符串
String JwtToken = Jwts.builder()
// 设置jwt头信息 头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "HS256")
// 设置分类
.setSubject("guli-user")
// 设置发行时间
.setIssuedAt(new Date())
// 设置过期时间,用得到当前时间加EXPIRE过期时间,为jwt字符串过期的时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
// 载荷部分---主体部分 存储用户信息
.claim("id", id)
.claim("nickname", nickname)
// hash签名 用APP_SECRET密钥进行加密
// 用头部中的算法已经定义的密钥进行加密
.signWith(SignatureAlgorithm.HS256, APP_SECRET)
.compact();
return JwtToken;
}
/**
* 传入token判断 ,判断token是否存在与有效
* @param jwtToken
* @return
*/
public static boolean checkToken(String jwtToken) {
// 1.判断token是否为空 ,为空则返回false
if(StringUtils.isEmpty(jwtToken)) return false;
try {
// 2.不为空,利用密钥解密token,如果解密成功则存在也token有效,如果过期了
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 传入http请求,判断token是否存在与有效
* @param request
* @return
*/
public static boolean checkToken(HttpServletRequest request) {
try {
// 1.从http请求中获取token信息,并判断token是否为空
String jwtToken = request.getHeader("token");
if(StringUtils.isEmpty(jwtToken)) return false;
// 利用加密密钥进行解密,如果解密成功则该token存在且有效
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 根据token获取会员id
* @param request
* @return
*/
public static String getMemberIdByJwtToken(HttpServletRequest request) {
// 把请求头header中的token取出,判断是否存在
String jwtToken = request.getHeader("token");
if(StringUtils.isEmpty(jwtToken)) return "";
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
// 获取token的主体部分claims
Claims claims = claimsJws.getBody();
// 获取id
System.out.println("id"+(String)claims.get("id"));
return (String)claims.get("id");
}
}
用户微服务
创建用户微服务模块ucenter
uncenter_member表解析
利用mybatis-plus代码生成器生成代码
配置文件yml
server:
port: 8150
spring:
application:
name: edu-micorService-uCenter
redis:
host: 192.168.0.113
port: 6379
database: 0
timeout: 18000000
lettuce:
pool:
max-active: 20
max-wait: -1 #最大阻塞时间 负数表示没有限制
max-idle: 5
min-idle: 0
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mybatisdemo?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath:xml/*Mapper.xml
#eureka:
# client:
# service-url:
# defaultZone: http://127.0.0.1:9101/eureka/
# instance:
# prefer-ip-address: true
# 微信开放平台 appid
wx:
open:
app_id: wxed9954c01bb89b47
app_secret: a7482517235173ddb4083788de60b90e
redirect_url: http://guli.shop/api/ucenter/wx/callback
创建用户登录和注册接口
创建LoginVo和RegisterVo用于数据封装
LoginVo
登录对象
package com.online.edu.eduservice.entity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* @author Double strong
* @date 2020/3/28 20:49
*/
@Data
@ApiModel(value = "登录对象",description = "登录对象")
public class LoginVo {
@ApiModelProperty(value = "电话号码")
private String mobile;
@ApiModelProperty(value = "密码")
private String password;
}
RegisterVo
注册对象
package com.online.edu.eduservice.entity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* @author Double strong
* @date 2020/3/28 20:51
*/
@Data
@ApiModel(value="注册对象", description="注册对象")
public class RegisterVo {
@ApiModelProperty(value = "昵称")
private String nickname;
@ApiModelProperty(value = "手机号")
private String mobile;
@ApiModelProperty(value = "密码")
private String password;
@ApiModelProperty(value = "验证码")
private String code;
}
创建Controller编写登录和注册方法
package com.online.edu.eduservice.controller;
import com.guli.common.constants.R;
import com.guli.common.utils.JwtUtils;
import com.online.edu.eduservice.entity.LoginVo;
import com.online.edu.eduservice.entity.RegisterVo;
import com.online.edu.eduservice.entity.UcenterMember;
import com.online.edu.eduservice.service.UcenterMemberService;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* <p>
* 会员表 前端控制器
* </p>
*
* @author doublestrong
* @since 2020-03-15
*/
@RestController
@RequestMapping("/ucenter/ucenterMember")
//@CrossOrigin
public class UcenterMemberController {
@Resource
private UcenterMemberService ucenterMemberService;
@GetMapping("count-register/{day}")
public R registerCount(@PathVariable("day") String day)
{
Integer count = ucenterMemberService.selectRegisterCount(day);
return R.ok().data("countRegister", count);
}
@ApiOperation(value = "会员登录")
@PostMapping("/login")
public R login(@RequestBody LoginVo loginVo) {
String token = ucenterMemberService.login(loginVo);
// 登陆成功之后返回登陆令牌token 用于实现单点登录
return R.ok().data("token", token);
}
@ApiOperation(value = "会员注册")
@PostMapping("/registery")
public R register(@RequestBody RegisterVo registerVo) {
ucenterMemberService.register(registerVo);
return R.ok();
}
// 登录之后得到token,根据token要得到用户信息(id,nickname)
@ApiOperation(value = "根据token获取登录信息")
@GetMapping("/auth/getLoginInfo")
public R getLoginInfo(HttpServletRequest request){
// 由jwt工具类 传入请求
String id = JwtUtils.getMemberIdByJwtToken(request);
UcenterMember member = ucenterMemberService.getById(id);
return R.ok().data("userInfo", member);
}
@GetMapping("/getMemberInfo/{id}")
public UcenterMember getbyId(@PathVariable("id") String id)
{
UcenterMember uc = ucenterMemberService.getById(id);
return uc;
}
}
登录业务后端详解
业务流程
1、 传入LoginVo校验登录参数(前端校验和后端校验都要做)
2、 在数据库中根据电话号码获取用户,判断用户是否存在
如果不存在则抛出异常throw new GuliException(20001,"error 不存在该用户");
3、 存在则进一步校验密码
由于用户密码是经过md5加密后保存的,所以校验前先用MD5解密
这里用到了MD5工具类,用于对用户密码进行加解密
4、 校验密码失败继续抛出错误
throw new GuliException(20001,"error 密码错误");
5、 校验密码成功则进一步判断该用户是否被禁用
若被禁用 throw new GuliException(20001,"error 该用户被禁用");
6、 上述过程都校验通过则使用jwt工具类,传入用户id和用户名生成token字符串用于单点登录
String jwtToken = JwtUtils.getJwtToken(ucenterMember.getId(), ucenterMember.getNickname());
7、返回登陆成功的jwttoken令牌
实现代码
@Override
public String login(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
// 校验参数是否为空
if(StringUtils.isEmpty(mobile)||StringUtils.isEmpty(password))
{
// 当两个有一个为空的时候就抛出异常
throw new GuliException(20001,"error 用户名或者密码错误");
}
// 根据电话号码获取会员
QueryWrapper<UcenterMember> queryWrapp=new QueryWrapper<>();
queryWrapp.eq("mobile",mobile);
UcenterMember ucenterMember = baseMapper.selectOne(queryWrapp);
// 判断是否存在该用户
if (ucenterMember==null)
{
// 不存在该用户
throw new GuliException(20001,"error 不存在该用户");
}
// 如果存在该用户,则进一步校验密码
String pwd = ucenterMember.getPassword();
// 将用户输入的密码经过MD5加密后与数据库对比
String encrypt = MD5.encrypt(password);
System.out.println(encrypt);
if(!encrypt.equals(pwd))
{
// 校验失败
throw new GuliException(20001,"error 密码错误");
}
// 判断该用户是否被禁用
if(ucenterMember.getIsDisabled()==true) {
throw new GuliException(20001,"error 该用户被禁用");
}
// 校验成功
// 使用JWT生成token字符串 把用户信息(id ,nickname)放到jwt字符串中
String jwtToken = JwtUtils.getJwtToken(ucenterMember.getId(), ucenterMember.getNickname());
// 返回登陆成功的令牌
return jwtToken;
}
登录业务前端详解
业务流程
1、 登录页面发送LoginVo给后端
2、 判断是否登录成功
若成功拿到后端返回token信息放到cookie中,token信息中有用户id和用户名
cookie.set('guli_token',res.data.data.token, { domain: 'localhost' })
cookie就存放在request请求中, 在这之后发送请求的话,请求拦截器会 去判断请求中是否有cookie值,有的话放到请求头中
3、 向后端发送token,根据token获取用户信息放到cookie中
把返回的用户信息放到cookie中,通过拦截器发送到请求头中
cookie.set('guli_ucenter', res.data.data.userInfo,{ domain: 'localhost' })
4、跳转到首页
在首页中创建从cookie中获取信息的方法完成登录
前端如何向后端发送token,且根据token获取用户信息
前端创建拦截器,把cookie中的token值放到请求头中。
service.interceptors.request.use(
config =>{
if(cookie.get('guli_token'))
{
// 如果存在该token值,把该token放到request请求头header中
config.headers['token']=cookie.get('guli_token');
}
return config
},
error =>{
return Promise.reject(error);
}
)
// 登录之后得到token,根据token要得到用户信息(id,nickname)
@ApiOperation(value = "根据token获取登录信息")
@GetMapping("/auth/getLoginInfo")
public R getLoginInfo(HttpServletRequest request){
// 由jwt工具类 传入请求
String id = JwtUtils.getMemberIdByJwtToken(request);
UcenterMember member = ucenterMemberService.getById(id);
return R.ok().data("userInfo", member);
}
注册业务后端详解
业务流程
1、 前端发送RegisterVo(前端也做校验)注册信息给后端
2、 对用户名、电话号码、密码、验证码进行校验
1、 上述四个参数均不能为空,为空则抛出异常
throw new GuliException(20001,"error 注册信息不能为空");
2、 判断手机号数据库中是否存在,若存在
throw new GuliException(20001,"error 手机号已注册过");
3、 从redis中获取保存的验证码与输入验证码进行判断(有时间限制)
1、若验证码比较不一致,抛出异常
throw new GuliException(20001,"error 验证码输入错误");
4、 上述过程都无异常的情况下,将注册用户信息添加到数据库中
注意:密码添加到数据库中的时候需要经过MD5加密
且设置用户为非禁用状态
注册业务前端详解
1、 短信验证码倒计时牌效果显示
2、 前端对数据进行校验
3、 若注册成功则进行路由跳转到登录页面
this.$router.push({path:'/login'})
倒计时功能实现
timeDown() {
//一个定时时间,每秒执行一次
let result = setInterval(() => {
// 每隔一秒执行一次 second--
--this.second;
// 把现在的second给到codeTest
this.codeTest = this.second
// 如果秒数小于1.发送验证码按钮恢复成可点击,可重新获取验证码
if (this.second < 1) {
//停止定时器
clearInterval(result);
//验证码发送按钮可点击
this.sending = true;
//this.disabled = false;
this.second = 60;
this.codeTest = "获取验证码"
}
}, 1000);
},