转自连接: medium.com/quick-code/…
原文作者: Timur Shafigullin
1. 介绍
我们经常需要验证向服务器发送请求的用户, 还需要确保客户端与服务器之间的传输安全. 这就是 access token 帮我们做的, 尤其是 JWT.
JSON Web Token (JWT) 是基于 json 格式创建 access token 的开源标准(RFC 7519). 用来在客户端服务器应用中传递授权信息.
服务器负责创建 tokens, 并通过密钥加密, 传输到客户端, 客户端之后使用 token 来确认自己的身份.
出于安全性的考虑, 我们的 jwt 需要有一个有限的生命周期. 为了及时更新, 用户需要确保自己身份的真实性. 接下来我们会在客户端不保存用户数据的情况下实现这个功能.
2. 认证与授权
- 认证: 是用户信息的验证(通常是登录信息/密码对).
- 授权: 是指给个人或群体执行特定操作的权限, 是检查用户是否有权访问特定资源的程序.
- 正常的认证与授权过程:
- 用户向服务器发送认证数据(登录信息/密码对), 即 认证处理.
- 如果数据正确, 服务器使用必要的信息(身份id, 权限, token 生命周期等)生成 jwt token.
- 用户每次向服务器发送请求都要携带接收到的 token.
- 服务器基于 token 中包含的信息(权限, 生命周期等)决定是否给用户相应的访问权限.
3. Refresh Token
- 为了实现不保存用户数据也能够更新 jwt, 需要引入新的概念, 例如 Refresh Token.
认证 token 被称为
Access Token, 用来更新 Access Token 的第二 token 被称为Refresh Token. 将其保存在客户端, 代替登录信息/密码对. - Refresh 通常比 Access Token "存活"的时间长, 例如一个月或两个月.
- 我们将 Refresh Token 存放在数据库, 每次客户端需要更新 Access Token 我们就验证它. 它是一个随机生成的字符串(例如: Lxd6bj7w33GEX1GOSgzCNZWNSMskaUmPwgG6uM)
4. Refresh Token 更新
- 客户端通过登录信息/密码对进行验证.
- 如果成功, 服务器创建新的 Access Token 和 Refresh Token, Refresh Token 和 他的生命周期一起存放到数据库.
- 服务器以这个形式响应给客户端:
{ "expired_at": ..., "access_token": ..., "refresh_token": ... } - 客户端保存这些信息.
- 每次请求前, 客户端检测 Access Token 是否过期. 如果未过期, 请求携带 token 一起发送.
- 为了刷新token, 客户端发送 Refresh Token 到指定的 api 路径(例如: /v1/account/refresh-token).
- 服务器通过查询数据库, 对比发送的 refresh token 是否正确, 并且检测是否过期.
- 如果 refresh token 过期, 或者数据库不存在此 token, 则取消认证并向客户端返回 401 错误.
- 如果 refresh token 存在并且未过期, 则创建新的 access token 并更新 refresh token, 以 步骤3 的格式返回给客户端.
- 客户端现在可以通过新的 access token 继续进行请求操作.
5. 实现
原工程代码用 Swift Vapor 实现, 此处用 Java Spring Boot 实现.
5.1. 创建 JWT Access Token
5.2. 创建 Refresh Token
代码更新
5.2.1. JwtTokenProvider 更新
- 更新 generateToken 返回值, 新增 generateRefreshToken 方法等.
JwtTokenProvider
package com.css.baseboot.security;
import java.util.Date;
import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.css.baseboot.model.vo.VoTokenResponse;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
/**
*
* @Author erDuo
* @Date 2020年3月10日 下午5:02:09
* @Description
*/
@Component
public class JwtTokenProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
@Value("${app.jwtSecret}")
private String jwtSecret;
@Value("${app.jwtExpirationInMs}")
private int jwtExpirationInMs;
/**
* 生成 token信息
* 包含 access token, refresh token 及 access token 过期时间
* @param authentication
* @return
*/
public VoTokenResponse generateToken(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Date now = new Date();
Date accessExpiredAt = new Date(now.getTime() + jwtExpirationInMs);
String accessToken = Jwts.builder()
.setSubject(userPrincipal.getId())
.setIssuedAt(new Date())
.setExpiration(accessExpiredAt)
.signWith(SignatureAlgorithm.HS512, jwtSecret).compact();
String refreshToken = generateRefreshToken();
return new VoTokenResponse(accessToken, refreshToken, accessExpiredAt);
}
/**
* 生成 access token 方法, 可以在不使用refresh token 时使用
* @param authentication
* @return
*/
public String generateAccessToken(Authentication authentication) {
// TODO: 可以把一些权限信息编译到 jwt 中, 之后就不用重复查询数据库
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(userPrincipal.getId())
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret).compact();
}
/**
* 生成 refresh token, 长度为40的大小写字母数字随机字符串
* @return
*/
public String generateRefreshToken() {
return RandomStringUtils.randomAlphanumeric(40);
}
/**
* 从token中获取user id
* @param token
* @return
*/
public String getUserIdFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return String.valueOf(claims.getSubject());
}
/**
* 验证token
* @param authToken
* @return
*/
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException ex) {
logger.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
logger.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
logger.error("JWT claims string is empty.");
}
return false;
}
}
5.2.2. AuthController 更新
- 更新 authenticateUser 返回值 ,新增 refreshToken 方法等.
AuthController
package com.css.baseboot.web.controller;
import java.net.URI;
import java.time.Instant;
import java.util.Collections;
import java.util.Set;
import javax.validation.Valid;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.css.baseboot.exception.AppException;
import com.css.baseboot.exception.UnauthorizedException;
import com.css.baseboot.model.dto.DtoRefreshToken;
import com.css.baseboot.model.entity.base.RefreshToken;
import com.css.baseboot.model.entity.base.RoleName;
import com.css.baseboot.model.entity.base.SysRole;
import com.css.baseboot.model.entity.base.SysUser;
import com.css.baseboot.model.payload.ApiResponse;
import com.css.baseboot.model.payload.LoginRequest;
import com.css.baseboot.model.payload.SignUpRequest;
import com.css.baseboot.model.vo.VoTokenResponse;
import com.css.baseboot.model.vo.Permission.VoLoginInfo;
import com.css.baseboot.security.CurrentUser;
import com.css.baseboot.security.CustomUserDetailsService;
import com.css.baseboot.security.JwtTokenProvider;
import com.css.baseboot.security.UserPrincipal;
import com.css.baseboot.service.RefreshTokenService;
import com.css.baseboot.service.SysRoleService;
import com.css.baseboot.service.SysUserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
/**
* @Author erDuo
* @Date 2020年3月10日 下午9:46:43
* @Description
*/
@RestController
@RequestMapping("/api/auth")
@Api(value = "认证操作")
public class AuthController {
@Autowired
AuthenticationManager authenticationManager;
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
RefreshTokenService refreshTokenService;
@Autowired
SysUserService userService;
@Autowired
SysRoleService roleService;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
JwtTokenProvider tokenProvider;
@PostMapping("/refreshToken")
@ResponseBody
public VoTokenResponse refreshToken(@Valid @RequestBody DtoRefreshToken dtoRefreshToken) {
RefreshToken refreshToken = refreshTokenService.findByToken(dtoRefreshToken.getRefreshToken())
.orElseThrow(() -> new UnauthorizedException("刷新token验证未通过"));
if(Instant.now().isAfter(refreshToken.getExpiredAt())) {
refreshTokenService.delete(refreshToken);
throw new UnauthorizedException("token已过期, 请重新登录");
}
UserDetails userDetails = customUserDetailsService.loadUserById(refreshToken.getUserId());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
VoTokenResponse token = tokenProvider.generateToken(authentication);
refreshToken.setToken(token.getRefreshToken());
refreshTokenService.update(refreshToken);
return token;
}
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsernameOrEmail(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
VoTokenResponse token = tokenProvider.generateToken(authentication);
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
RefreshToken refreshTokenNew = new RefreshToken(token.getRefreshToken());
RefreshToken refreshToken = refreshTokenService.findByUserId(userPrincipal.getId()).orElse(refreshTokenNew);
if (refreshToken.getUuid() != null) {
refreshToken.setToken(token.getRefreshToken());
refreshTokenService.update(refreshToken);
} else {
refreshTokenService.save(refreshToken);
}
return ResponseEntity.ok(token);
}
@PostMapping("/signup")
public ResponseEntity<ApiResponse> registerUser(@Valid @RequestBody SignUpRequest signUpRequest) {
if (userService.existsByPhone(signUpRequest.getPhone())) {
return new ResponseEntity<ApiResponse>(new ApiResponse(false, "手机号已注册!"), HttpStatus.BAD_REQUEST);
}
if (userService.existsByEmail(signUpRequest.getEmail())) {
return new ResponseEntity<ApiResponse>(new ApiResponse(false, "邮箱已注册!"), HttpStatus.BAD_REQUEST);
}
if (userService.existsByUsername(signUpRequest.getUsername())) {
return new ResponseEntity<ApiResponse>(new ApiResponse(false, "用户名已存在!"), HttpStatus.BAD_REQUEST);
}
if (userService.existsByNickname(signUpRequest.getName())) {
return new ResponseEntity<ApiResponse>(new ApiResponse(false, "昵称已存在!"), HttpStatus.BAD_REQUEST);
}
// TODO: 自定义用户区划
SysUser user = new SysUser(signUpRequest.getUsername(), signUpRequest.getPassword(), signUpRequest.getName(),
signUpRequest.getEmail(), signUpRequest.getPhone(), "11", "北京");
user.setPassword(passwordEncoder.encode(user.getPassword()));
SysRole userRole = roleService.findByRoleName(RoleName.ROLE_USER)
.orElseThrow(() -> new AppException("未设置用户权限."));
user.setRoles(Collections.singleton(userRole));
userService.save(user);
// TODO: 注册成功后的跳转
URI location = ServletUriComponentsBuilder.fromCurrentContextPath().path("/users/{username}")
.buildAndExpand(user.getUsername()).toUri();
return ResponseEntity.created(location).body(new ApiResponse(true, "用户注册成功!"));
}
/**
* 查询当前登录用户的信息
*
* @return
*/
@RequestMapping(value = "/info/{timestamp}", method = RequestMethod.GET)
@ApiOperation(value = "获取登录人详细信息", notes = "获取登录人详细信息")
@ResponseBody
public VoLoginInfo getInfo(@CurrentUser UserPrincipal currentUser) {
VoLoginInfo info = new VoLoginInfo();
BeanUtils.copyProperties(currentUser, info);
Set<String> roles = currentUser.getRoles();
Set<String> menus = currentUser.getMenus();
Set<String> permissions = currentUser.getPermissions();
info.setRoleList(roles);
info.setMenuList(menus);
info.setPermissionList(permissions);
return info;
}
@GetMapping("/signout/success")
public String signout() {
return "退出成功,请重新登录";
}
}
5.2.3. 新增 RefreshToken 类, 及RefreshTokenService, RefreshTokenRepository 类
RefreshToken
package com.css.baseboot.model.entity.base;
import java.time.Duration;
import java.time.Instant;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import com.css.baseboot.config.TokenConfig;
import lombok.Data;
/**
* @Author erDuo
* @Date 2020年3月17日 上午11:51:54
* @Description
*/
@Entity
@Table(name = "REFRESH_TOKEN")
@EntityListeners(AuditingEntityListener.class)
@Data
public class RefreshToken {
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "org.hibernate.id.UUIDGenerator")
@Column(length = 36)
private String uuid;
@CreatedDate
@Column(nullable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@Column(nullable = false)
private Instant expiredAt;
@CreatedBy
@Column(nullable = false, length = 36)
private String userId;
@Column(nullable = false, length = 40)
private String token;
public RefreshToken() {
}
public RefreshToken(String token) {
Instant now = Instant.now();
Duration gap = Duration.ofDays(TokenConfig.refreshTokenExpiredDays);
this.token = token;
this.expiredAt = now.plus(gap);
}
}
RefreshTokenService
package com.css.baseboot.service;
import java.util.Optional;
import com.css.baseboot.model.entity.base.RefreshToken;
/**
* @Author erDuo
* @Date 2020年3月17日 下午10:51:22
* @Description
*/
public interface RefreshTokenService extends BaseService<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
Optional<RefreshToken> findByUserId(String userId);
RefreshToken save(RefreshToken refreshToken);
RefreshToken update(RefreshToken refreshToken);
void delete(RefreshToken refreshToken);
}
RefreshTokenServiceImpl
package com.css.baseboot.service.impl;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.css.baseboot.config.TokenConfig;
import com.css.baseboot.model.entity.base.RefreshToken;
import com.css.baseboot.repository.RefreshTokenRepository;
import com.css.baseboot.service.RefreshTokenService;
/**
* @Author erDuo
* @Date 2020年3月17日 下午10:52:50
* @Description
*/
@Service
public class RefreshTokenServiceImpl extends BaseServiceImpl<RefreshToken, Long> implements RefreshTokenService{
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
public void setBaseDao(RefreshTokenRepository refreshTokenRepository) {
super.setBaseDao(refreshTokenRepository);
}
@Override
public Optional<RefreshToken> findByToken(String token) {
return refreshTokenRepository.findByToken(token);
}
@Override
public Optional<RefreshToken> findByUserId(String userId) {
return refreshTokenRepository.findByUserId(userId);
}
@Override
public void delete(RefreshToken refreshToken) {
refreshTokenRepository.delete(refreshToken);
}
@Override
public RefreshToken save(RefreshToken refreshToken) {
return refreshTokenRepository.save(refreshToken);
}
@Override
public RefreshToken update(RefreshToken refreshToken) {
Instant now = Instant.now();
Duration gap = Duration.ofDays(TokenConfig.refreshTokenExpiredDays);
refreshToken.setExpiredAt(now.plus(gap));
return refreshTokenRepository.save(refreshToken);
}
}
RefreshTokenRepository
package com.css.baseboot.repository;
import java.util.Optional;
import com.css.baseboot.model.entity.base.RefreshToken;
/**
* @Author erDuo
* @Date 2020年3月17日 下午2:11:26
* @Description
*/
public interface RefreshTokenRepository extends BaseRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
Optional<RefreshToken> findByUserId(String userId);
}
5.2.4. 新增 DtoRefreshToken 实体类, 接收 refresh token
DtoRefreshToken
package com.css.baseboot.model.dto;
import javax.validation.constraints.NotBlank;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* @Author erDuo
* @Date 2020年3月17日 下午2:39:01
* @Description
*/
@ApiModel(value = "刷新token所需参数", description = "刷新token操作参数")
@Data
@Accessors(fluent = false)
public class DtoRefreshToken {
@ApiModelProperty(notes = "token类型", required = true)
@NotBlank(message = "请输入token类型")
private String type;
@ApiModelProperty(notes = "refreshToken值", required = true)
@NotBlank(message = "请输入refreshToken值")
private String refreshToken;
}
5.2.5. JwtAuthenticationFilter更新
JwtAuthenticationFilter
package com.css.baseboot.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import com.css.baseboot.exception.UnauthorizedException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
boolean result = true;
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt)) {
if (tokenProvider.validateToken(jwt)) {
String userId = tokenProvider.getUserIdFromJWT(jwt);
UserDetails userDetails = customUserDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
result = false;
throw new UnauthorizedException("验证未通过");
}
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
if (ex instanceof UnauthorizedException) {
throw ex;
}
}
if (result) {
filterChain.doFilter(request, response);
}
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
5.2.6. 新增 UnauthorizedException 异常类
UnauthorizedException
package com.css.baseboot.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* @Author erDuo
* @Date 2020年3月17日 下午3:12:30
* @Description
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public class UnauthorizedException extends RuntimeException {
private static final long serialVersionUID = 2138206914933087234L;
public UnauthorizedException(String message) {
super(message);
}
public UnauthorizedException(String message, Throwable cause) {
super(message, cause);
}
}
5.2.7. 更新配置文件
#设置jwt过期时间为100秒
app.jwtExpirationInMs = 100000
6. postman 测试
- 登陆验证






7. 结论
- 使用 access + refresh token 结合的机制保证攻击者无法盗取用户数据.
- 为了更加安全, 可以为阻塞或者被盗的token设置黑名单.
- 为了优化存储, 可以定期的清除过期 refresh token 数据.
8. 思考
多次登录生成多个token或者每次登录更新token, 当前代码为每次登录更新token, 即数据库只保存一个token.