在项目中,用户的登录校验最常见的业务。
本文模拟一个业务场景:注册会员,登录网站,获取订单信息
说明:
- 访问订单信息时,用户需要登录过才可以访问;
- 用户前往注册登录或主页无需登录;
注册
用户注册需必填信息有用户名,密码,以及手机验证码,因此:
- 注册时要获取手机验证码;
- 验证码有60有效
- 验证码存储在redis中
- 验证后将用户信息入库
验证码的相关配置
# 自定义redis键值
redis:
key:
prefix:
otpCode: "member:otpCode:" #动态校验码前缀
expire:
otpCode: 60 #动态校验码过期时间
- redis序列化配置(代码略)
- redis属性配置对应类(代码略)
获取验证码
@Override
public String getOtpCode(String phone) {
// 1 当前用户是否注册
List<Member> members = memberService.getByPhone(phone);
if (!members.isEmpty()) {
throw new CustomException(ResultCodeEnum.EXISTED_USER);
}
// 2 60s验证码唯一且有效
String key = redisKeyPrefixConfig.getPrefix().getOtpCode() + phone;
if (redisTemplate.hasKey(key)) {
//throw new CustomException(ResultCodeEnum.OPTCODE_EFFECTIVE);
log.info("验证码未过期:phone=[{}],optCode=[{}]");
return (String) redisTemplate.opsForValue().get(key);
}
// 3 返回6位随机数字验证码
Random random = new Random();
StringBuffer optCode = new StringBuffer();
final int optSize = 6;
for (int i = 0; i < optSize; i++) {
optCode.append(random.nextInt(10));
}
log.info("新生成验证码:phone=[{}],optCode=[{}]", phone, optCode);
// 1min过期
long expire = redisKeyPrefixConfig.getExpire().getOtpCode();
redisTemplate.opsForValue().set(key, optCode.toString(), expire, TimeUnit.SECONDS);
return optCode.toString();
}
- 控制器
@GetMapping("/optCode")
public ResponseEntity<R> getOptCode(@RequestParam String phone) {
String optCode = ssoService.getOtpCode(phone);
return R.ok().data("optCode", optCode).buildResponseEntity();
}
注册
用户验证码通过后,需要将用户信息注册到数据中。考虑到安全性,用户的密码不可以明文储存在数据中,也不建议可揭秘,登录验验证时只需对比加密结果一直即可
- 引入spring-security依赖,里面包含hash盐加密工具
<!--spring security-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>RELEASE</version>
</dependency>
- 注册加密类
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
- 注册入库
@Override
public boolean register(RegisterQo registerQo) {
// 1. 校验验证码
String key = redisKeyPrefixConfig.getPrefix().getOtpCode() + registerQo.getPhone();
String redisOptCode = (String) redisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(redisOptCode) || !redisOptCode.equals(registerQo.getOtpCode())) {
throw new CustomException(ResultCodeEnum.UNMATCH_OPTCODE);
}
// 2. 验证码正确使用后删除
redisTemplate.delete(key);
// 3. 数据库插入记录,密码使用加密方式
Member member = new Member().setStatus(1).setMemberLevelId(4L);
BeanUtils.copyProperties(registerQo, member);
String password = passwordEncoder.encode(registerQo.getPassword());
member.setPassword(password);
return memberService.save(member);
}
- 控制器
@GetMapping("/optCode")
public ResponseEntity<R> getOptCode(@RequestParam String phone) {
String optCode = ssoService.getOtpCode(phone);
return R.ok().data("optCode", optCode).buildResponseEntity();
}
@PostMapping("/register")
public ResponseEntity<R> register(@Valid @RequestBody RegisterQo registerQo) {
ssoService.register(registerQo);
return R.ok().message("注册成功").buildResponseEntity();
}
测试
登录
单机session
- 校验用户登录信息;
- 登录后将用户记录存入session会话,下次访问时浏览器携带cookie即可;
验证
为简化代码,建议直接使用validator
类,在进入控制器时直接校验,校验错误可通过aop或全局异常的方式捕获错误信息
- 注册实体类
@Data
public class RegisterQo {
@NotBlank(message = "用户名不能为空")
@Length(min = 4,max = 20,message = "用户名长度必须在4-20字符之间")
private String username;
@NotBlank(message = "密码不能为空")
@Length(min = 4,max = 20,message = "密码长度必须在4-20字符之间")
private String password;
@NotBlank(message = "手机号不能为空")
@Length(min = 11,max = 11,message = "电话必须是11个字符")
@Pattern(regexp = "^1[3|4|5|6|7|8|9][0-9]\\d{8}$",message = "电话号码格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
@Length(min = 6,max = 6,message = "验证码必须6个字符")
private String otpCode;
}
- 异常捕获,为方便查看错误信息,这里直接将errors信息返回
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<R> error(MethodArgumentNotValidException e) {
log.error(ExceptionUtils.getMessage(e));
List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
String description = allErrors.stream().map(x -> x.getDefaultMessage()).reduce((x, y) -> x + "," + y).orElseGet(PARAM_ERROR::getMessage);
return new ResponseEntity<>(R.setResult(PARAM_ERROR).data("description",description), BAD_REQUEST);
}
- 进入mvc控制器后,校验用户名对应的密码
@Override
public Member login(String username, String password) {
// 1. 获取数据库用户信息
List<Member> members = memberService.getByUsername(username);
// 2. 密码加密匹配
if (CollectionUtils.isEmpty(members) || (!passwordEncoder.matches(password, members.get(0).getPassword()))) {
throw new CustomException(ResultCodeEnum.UNMATCH_UNAME_PWD);
}
// 3. 返回用户信息
Member member = members.get(0);
return member;
}
记录session
校验通过后,将用户的信息记录到session中
- 获取serlvet中的request类
public HttpServletRequest getRequest() {
return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
}
- 记录session,返回
@GetMapping("/login")
public ResponseEntity<R> sessionLogin(String username, String password) {
Member member = ssoService.login(username, password);
// 登录记录session
getHttpSession().setAttribute("member", member);
// TODO 返回member的vo类
return R.ok().message("登录成功").data("username", member.getUsername()).buildResponseEntity();
}
访问拦截
- 访问url时,首先进入前置拦截器,在这里判断session
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("进入前置拦截器...");
// session校验
if (ObjectUtils.isEmpty(request.getSession().getAttribute("member"))) {
printUnauthorized(response);
return false;
}
return true;
}
/**
* 该部分没有进入到mvc,无法使用自定义异常,需要从response返回
*
* @param response
* @throws Exception
*/
protected void printUnauthorized(HttpServletResponse response) throws Exception {
response.setHeader("Content-Type", "application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(401);
String result = new ObjectMapper().writeValueAsString(R.setResult(ResultCodeEnum.UNAUTHORIZED));
response.getWriter().print(result);
}
- 除指定url,其他访问都需要登录的session信息
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
@Autowired
private NoAuthUrlProperties noAuthUrlProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptorHandler())
.addPathPatterns("/**")
.excludePathPatterns(new ArrayList<>(noAuthUrlProperties.getShouldSkipUrls()));
}
@Bean
public AuthHandlerInterceptor authInterceptorHandler() {
return new AuthHandlerInterceptor();
}
}
模拟获取订单
若用户没有登录,没有记录session,那么将无法访问(若以登录,清楚cookie,或重启服务)
- 获取订单
@GetMapping
public ResponseEntity<R> getOrder() {
log.info("下订单...");
// session会话信息
Member member = (Member) getHttpSession().getAttribute("member");
return R.ok().message("测试订单调用").data("userId",member.getId()).buildResponseEntity();
}
- 测试
分布式session
上面的方案,在部署单台服务器时是可行的,但是如果部署了两台服务器
时,那么很可能出现这种情况:用户登录成功后,访问订单时一开始可以访问,刷新后又不能访问了。
这是由于session信息是存储在服务器端的,也就是在jvm中,以concurrentMap形式存储。
用户登录时,session在记录在A服务器中,B服务器并没有该session,导致代理转发到B服务器时会没有访问权限
要解决分布式环境下的问题,有两种思路:
- 实时同步:当某一台服务器中session发生变化,其他服务器也会收到通知,实时变化
- session共享:将session信息从服务器中剥离,作为一个单独的服务库存储,所有服务器操作session时,只需要访问这个数据服务
相比较:第一种方案虽然可行,但是维护成本大,需要引入通信组件,且增大了服务器大压力。第二中相对简单,这里使用redis缓存作为存储
改动
- 引入spring session依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- 修改yml
spring:
session:
store-type: redis
- 启用redis session,这里设置过期时间1h
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
测试
- 修改nginx.conf,使用负载均衡策略
upstream mall-member {
server localhost:9110 weight=3;
server localhost:9111 weight=3;
}
server {
listen 3000;
server_name localhost;
location / {
proxy_pass http://mall-member;
root html;
index index.html index.htm;
}
}
- 登录访问
JWT版本
使用redis共享session虽然很好的解决了同步问题,但是有三个明显缺点:1.随着用户规模扩大,数据库成本加大;2.session只适合存储少量登录现象,不适合携带更多的类似头像权限等信息;3.cookie不能跨域
简介
JWT(JSON Web Token),用户会话信息存储在客户端浏览器,它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象进行安全传输信息。这些信息可以通过对称/非对称方式进行签名,防止信息被串改。且它本身就是json格式,对前后端交互友好。
缺点:
- jwt在有效期内是可以使用的,可多个token并存,修改密码依然可用。一旦泄漏,存在风险
- 加密后内容较长,占用带宽
结构
JWT包含三个部分:Header.Payload.Signature,内容以.
分割
-
Header: 采用Base64加密,描述 JWT 的元数据,主要包含了签名算法和令牌(token)灵性
-
Payload: 用来存放实际需要传递的数据,可自定义。JWT 规定了7个官方字段,供选用:
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分
-
Signature:signature 部分是使用密钥(secret)对前两部分的签名,防止数据篡改。默认采用HS256加密算法
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
使用方式
客户端收到服务器返回的 JWT,可以储存在 Cookie 或 localStorage。客户端每次与服务器通信,都要带上这个 JWT。也可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization
字段里面。
Authorization: Bearer <token>
代码整合
- 引入jjwt
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
- 自定义jwt配置
#jwt配置
jwt:
header: Authorization #JWT存储的请求头
secret: mall-member-secret #JWT加解密使用的密钥
expire: 604800 #JWT的超期限时间(60*60*24)
prefix: Bearer #JWT负载中拿到开头
- jwt加密解密方法(代码略)
- 修改前置拦截器
public final static String GLOBAL_JWT_MEMBER_INFO="jwt:member:info";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("进入前置拦截器...");
String authorization = request.getHeader(jwtProperties.getHeader());
// 1.校验token
if (StringUtils.isEmpty(authorization)||!authorization.startsWith(jwtProperties.getPrefix())) {
printUnauthorized(response);
return false;
}
// 2. 去除Bearer前缀
String token = authorization.substring(jwtProperties.getPrefix().length());
// 3. 解析
Claims claims = jwtKit.parse(token);
if (claims == null) {
printUnauthorized(response);
return false;
}
request.setAttribute(GLOBAL_JWT_MEMBER_INFO,claims);
return true;
}
- 登录控制器
@GetMapping("/login")
public ResponseEntity<R> jwtLogin(String username, String password) {
Member member = ssoService.login(username, password);
// 返回jwt
HashMap<String, String> map = new HashMap<>(2);
map.put("token", jwtKit.generate(member));
map.put("header", jwtProperties.getPrefix());
return R.ok().message("登录成功").data("jwt", map).buildResponseEntity();
}
详细过程,可参考源代码:github.com/chetwhy/clo…