Web 应用的安全性包括用户认证(Authentication)和用户授权(Authorization)两个部分。
认证
用户认证指的是验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。认证成功后将生成一个Token返回前端,供后续操作的验证。
授权
用户授权指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。
一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。
Spring和Token整合,其实是对Spring Security 添加登陆和验证filter,不再以session作为登陆验证的标准,而是每次从请求中取token进行校验,如果token是正确的,解析出用户信息并交给Spring Security进行下一步操作。
登陆请求
请求为:/auth/login,RequsetBody 为用户名和密码。请求到达之后,会先到达TokenFilter,如果是初次登陆未携带token,请求将到达具体的登陆处理controller。如果不是初次登陆,TokenFilter将刷新失效时间,然后将新的LoginUser放入security上下文。
登陆时,service先通过用户名查数据库得到user对象,然后比对请求中的密码和查出来的对象中的密码。
因为库中的密码是加过密的,因此需要不能直接比对,需要将request中的密码编码后和库中的密码进行对比,如果flag为true,则密码校验通过。
boolean flag = bCryptPasswordEncoder.matches(password, userEntity.getPassword());
验证通过后,service将查询好的user放入security上下文,然后生成Token:
UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken(userEntity.getUsername(), password);
Authentication authentication = authenticationManager.authenticate(upToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
接下来进行token的生成及缓存,先生成token并set到loginUser中:
loginUser.setToken(UUID.randomUUID().toString());
然后将token和用户缓存到redis,这里缓存的token是uuid值:f53ca20a-d4bb-45ae-9158-feaf16b6ea78
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expire * 1000);
redisTemplate.boundValueOps(getTokenKey(loginUser.getToken())).set(loginUser, expire, TimeUnit.SECONDS);
然后通过loginUser创建JWT令牌,做了加密处理:eyJhbGciOiJIUzI1NiJ9.eyJMT0dJTl9VU0VSX0tFWSI6ImY1M2NhMjBhLWQ0YmItNDVhZS05MTU4LWZlYWYxNmI2ZWE3OCJ9.7jk49ZC1p2pGX4Xigey02a9YFXn5WeM2I0PDlug2yMo
Map claims = new HashMap<>();
claims.put(LOGIN_USER_KEY, loginUser.getToken());
String jwtToken = Jwts.builder().setClaims(claims)
.signWith(SignatureAlgorithm.HS256, getKeyInstance())
.compact();
然后把令牌返回前端:
return new Token(jwtToken, loginUser.getLoginTime());
其他请求
请求为 /auth/current,请求头为 token
首先经过 TokenFilter 过滤器 public class TokenFilter extends OncePerRequestFilter{},执行其 doFilterInternal 方法
然后获得jwtToken:eyJhbGciOiJIUzI1NiJ9.eyJMT0dJTl9VU0VSX0tFWSI6ImQ0MzFlOGMzLTA2ZTctNDE5OC1hZThjLWYwOTkwYmQ3OTJmMiJ9.onJvDZDHoDek7WFxrKHcxIwl-RYStOq1uQKrJEmwStE
jwtToken = request.getHeader(TOKEN_KEY);
然后将jwtToken解析为生成的uuid的token:LOGIN_USER_KEY -> d431e8c3-06e7-4198-ae8c-f0990bd792f2
Map jwtClaims = Jwts.parser()
.setSigningKey(getKeyInstance())
.parseClaimsJws(jwtToken).getBody();
token = MapUtils.getString(jwtClaims, LOGIN_USER_KEY);
然后使用token去redis中查询,如果正确则得到 LoginUser 对象:
loginUser = redisTemplate.boundValueOps(getTokenKey(token)).get();
如果 loginUser 不为 null,那么将过期时间与当前时间对比,临近过期10分钟内的话,自动刷新redis缓存,也就是重新保存
redisTemplate.boundValueOps(getTokenKey(loginUser.getToken())).set(loginUser, expire, TimeUnit.SECONDS);
然后重新生成 authentication 对象,通过 SecurityContextHolder 存入 SecurityContext
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
这样filter通过后才到达相应的controller,到达后执行方法得到需要的loginUser
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (LoginUser) authentication.getPrincipal();
// 代码在 UserUtils 中
首先定义一个过滤器用于从请求中获取token
package com.wensi.auth.adapter;
@Component
public class TokenFilter extends OncePerRequestFilter {
public static final String TOKEN_KEY = "token";
private static final Long MINUTES_10 = 10 * 60 * 1000L;
@Autowired
private TokenService tokenService;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String jwtToken = getToken(request);
if (StringUtils.isNotBlank(jwtToken)) {
LoginUser loginUser = tokenService.getLoginUser(jwtToken);
if (loginUser != null) {
loginUser = checkLoginTime(loginUser);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
//校验时间, 过期时间与当前时间对比,临近过期10分钟内的话,自动刷新缓存
private LoginUser checkLoginTime(LoginUser loginUser) {
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MINUTES_10) {
String token = loginUser.getToken();
loginUser = (LoginUser) userDetailsService.loadUserByUsername(loginUser.getUsername());
loginUser.setToken(token);
tokenService.refresh(loginUser);
}
return loginUser;
}
//根据参数或者header获取token
public static String getToken(HttpServletRequest request) {
String token = request.getParameter(TOKEN_KEY);
if (StringUtils.isBlank(token)) {
token = request.getHeader(TOKEN_KEY);
}
return token;
}
}
由于登录时,请求中并未携带token,因此请求下一步会到达具体的登录controller
package com.wensi.web.auth;
@Api(tags = "登陆管理")
@RestController
@RequestMapping("/auth")
public class LoginController {
@ApiOperation(value = "登录")
@PostMapping("/login")
public Resp login(@RequestBody @Valid LoginReq loginReq){
Token token = authService.login(loginReq);
return Resp.success(token);
}
@ApiOperation(value = "当前用户")
@GetMapping(path = "/current")
public Resp current() {
LoginUser current = UserUtils.current();
return Resp.success(current);
}
}
然后到达service进行校验处理,该service中用到了很多SpringSecurity中的类,将认证信息set到了SecurityContext上下文,最后又调用了TokenService生成了JwtToken。
package com.wensi.auth.domain.service;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Override
public Token login(LoginReq loginReq) {
//校验用户
UserEntity userEntity = Optional.ofNullable(userDao.findByUsername(loginReq.getUsername()))
.orElseThrow(() -> ErrState.USER_NOT_FOUND.err(loginReq.getUsername()));
return this.checkLogin(userEntity, loginReq.getPassword());
}
//登录认证
private Token checkLogin(UserEntity userEntity, String password){
//校验密码
boolean flag = bCryptPasswordEncoder.matches(password, userEntity.getPassword());
if (!flag){
throw ErrState.PASSWORD_ERR.err();
}
if (null != userEntity.getStatus() && UserStatus.LOCKED.equals(userEntity.getStatus())){
throw ErrState.ACCOUNT_LOCK.err();
}
if (null != userEntity.getStatus() && UserStatus.DISABLE.equals(userEntity.getStatus())){
throw ErrState.ACCOUNT_DISABLE.err();
}
UsernamePasswordAuthenticationToken upToken =
new UsernamePasswordAuthenticationToken(userEntity.getUsername(), password);
Authentication authentication = authenticationManager.authenticate(upToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
//保存token到redis, 生成jwtToken
UserInfo userInfo = userAssemble.userInfo(userEntity);
LoginUser loginUser = new LoginUser();
BeanUtils.copyProperties(userInfo, loginUser);
return tokenService.saveToken(loginUser);
}
}
该类主要完成token生成以及将token和当前用户信息缓存到redis
package com.wensi.auth.domain.service;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.data.redis.core.RedisTemplate;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
@Slf4j
@Component
public class TokenServiceJWTImpl implements TokenService {
@Value("${token.expire.seconds}")
private Integer expire;
@Value("${token.jwtSecret}")
private String jwtSecret;
private static Key KEY = null;
private static final String LOGIN_USER_KEY = "LOGIN_USER_KEY";
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SysLogService sysLogService;
@Autowired
private HttpServletRequest request;
@Override
public Token saveToken(LoginUser loginUser) {
loginUser.setToken(UUID.randomUUID().toString());
cacheLoginUser(loginUser);
// 登陆日志
String ip = Optional.ofNullable(request.getHeader("X-Real-IP"))
.filter(StringUtils::isNotBlank).orElse(request.getRemoteHost());
SysLogReq sysLogReq = new SysLogReq(loginUser.getId(), ip,
loginUser.getNickName(),"登录", 1, null);
sysLogService.add(sysLogReq);
String jwtToken = createJWTToken(loginUser);
return new Token(jwtToken, loginUser.getLoginTime());
}
//缓存token到redis
private void cacheLoginUser(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expire * 1000);
// 根据token将loginUser缓存
redisTemplate.boundValueOps(getTokenKey(loginUser.getToken()))
.set(loginUser, expire, TimeUnit.SECONDS);
}
//定制jwt令牌
private String createJWTToken(LoginUser loginUser) {
// 荷载部分放入token,通过该串可找到登陆用户
Map claims = new HashMap<>();
claims.put(LOGIN_USER_KEY, loginUser.getToken());
//签发令牌
String jwtToken = Jwts.builder().setClaims(claims)
.signWith(SignatureAlgorithm.HS256, getKeyInstance())
.compact();
return jwtToken;
}
//定制公钥
private Key getKeyInstance() {
if (KEY == null) {
synchronized (TokenServiceJWTImpl.class) {
if (KEY == null) {
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(jwtSecret);
KEY = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
}
}
return KEY;
}
@Override
public void refresh(LoginUser loginUser) {
cacheLoginUser(loginUser);
}
@Override
public LoginUser getLoginUser(String jwtToken) {
String token = parseJWT(jwtToken);
if (StringUtils.isNotBlank(token)) {
return redisTemplate.boundValueOps(getTokenKey(token)).get();
}
return null;
}
//解析jwt令牌, 拿到荷载中的token
private String parseJWT(String jwtToken) {
if ("null".equals(jwtToken) || StringUtils.isBlank(jwtToken)) {
return null;
}
try {
Map jwtClaims = Jwts.parser()
.setSigningKey(getKeyInstance())
.parseClaimsJws(jwtToken).getBody();
return MapUtils.getString(jwtClaims, LOGIN_USER_KEY);
} catch (ExpiredJwtException e) {
log.error("{}已过期", jwtToken);
} catch (Exception e) {
log.error("{}", e);
}
return null;
}
@Override
public boolean deleteToken(String jwtToken) {
String token = parseJWT(jwtToken);
if (StringUtils.isNotBlank(token)) {
String key = getTokenKey(token);
LoginUser loginUser = redisTemplate.opsForValue().get(key);
if (loginUser != null) {
redisTemplate.delete(key);
// 退出日志
String ip = Optional.ofNullable(request.getHeader("X-Real-IP"))
.filter(StringUtils::isNotBlank).orElse(request.getRemoteHost());
SysLogReq sysLogReq = new SysLogReq(loginUser.getId(), ip,
loginUser.getNickName(),"退出", 1, null);
sysLogService.add(sysLogReq);
return true;
}
}
return false;
}
//定制redisKey
private String getTokenKey(String token) {
return "tokens:" + token;
}
}
这里看一下Token、loginUser、UserInfo三者的嵌套关系
Token
token属性实际保存了生成的Jwt字符串
public class Token implements Serializable {
private static final long serialVersionUID = -164567294469931676L;
/**
* token
*/
private String token;
/**
* 登陆时间戳(毫秒)
*/
private Long loginTime;
}
LoginUser继承了UserInfo,UserInfo中保存了用户的相关信息
package com.wensi.auth.adapter.dto;
import org.springframework.security.core.userdetails.UserDetails;
public class LoginUser extends UserInfo implements UserDetails {
@Setter
@Getter
private String token;
/**
* 登陆时间
*/
@Setter
@Getter
private Long loginTime;
/**
* 过期时间
*/
@Setter
@Getter
private Long expireTime;
// 其他实现方法忽略
}
UserInfo
package com.wensi.user.assemble.resp;
@Data
public class UserInfo {
private Long id;
private String username;
private String password;
private String nickName;
private String headIconId;
private String phone;
private String email;
private Date birthday;
private int sex;
private UserStatus status;
private Date createTime;
private Date updateTime;
private List roles;
private List permissions;
}