1. JWT、Spring Security
若依(分离版)的登录、认证、授权使用到了JWT、Spring Security,下面先通过例子对JWT、Spring Security的用法进行介绍。
1.1 JWT
1.1.1 JWT简介
JWT全称JSON Web Token,由header、payload、signature这三部分组成,通过数字签名方式,以JSON对象为载体,在不同服务终端间安全得传输信息。JWT最常见的应用场景就是授权认证,用户登录后,会用JWT对生成的token进行加密,后续每个请求的请求头中都要携带加密后的值,系统在接收请求时,会用JWT进行解密,得到token,进行后续操作。
1.1.2 JWT案例
public static void main(String[] args) {
// JWT加密
String jwtStr = Jwts.builder()
// header部分
.setHeaderParam("type", "JWT")
.setHeaderParam("alg", "HS512")
// payload部分
.claim("user_name", "zs")
.claim("age", "20")
.setSubject("jwt_test")
.setId(IdUtils.fastUUID())
// signature
.signWith(SignatureAlgorithm.HS512, "abcdefghijklmnopqrstuvwxyz")
// 调用compact对这三部分进行处理,得到一个安全的JWT字符串
.compact();
System.out.println(jwtStr);
// jwtStr:
// eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ
// .eyJ1c2VyX25hbWUiOiJ6cyIsImFnZSI6IjIwIiwic3ViIjoiand0X3Rlc3QiLCJqdGkiOiJmZmIyNTg3My1lYTI2LTRkNTItOTVhYy1mMzgxODRkMWFkMjYifQ
// .jlR-tUzSFnqSq8zcJt8LqM5kuthoSrPr4HygbVjWu19w6CEG7WEAHf0qReSbRPp-i4FXi1K9x_s6XYZv_OKhrg
// JWT解密
Claims claims = Jwts.parser()
.setSigningKey("abcdefghijklmnopqrstuvwxyz")
.parseClaimsJws(jwtStr)
.getBody();
System.out.println(claims.get("user_name")); // zs
System.out.println(claims.get("sub")); // jwt_test
System.out.println(claims.getSubject()); // jwt_test
}
1.2 Spring Security应用案例简介
1.2.1 自定义用户认证逻辑
要进行账号密码校验,需要实现Spring Security提供的UserDetailsService和UserDetails接口、重写loadUserByUsername方法、返回UserDetails。
(1)UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private ISysUserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
// 通过登录页面中传入的用户名(页面中可能传入错误的用户名),从数据库查询用户信息
SysUser user = userService.selectUserByUserName(username);
// 自定义校验(略)
// 创建实现了UserDetails的LoginUser对象
return new LoginUser(user);
}
}
(2)LoginUser
public class LoginUser implements UserDetails {
private static final long serialVersionUID = 1L;
/**
* 用户信息
*/
private SysUser user;
public LoginUser() {
}
public LoginUser(SysUser user, Set<String> permissions) {
this.user = user;
}
public SysUser getUser() {
return user;
}
public void setUser(SysUser user) {
this.user = user;
}
// 重写UserDetails的getPassword、getUsername方法
// Spring Security会调用这getPassword校验密码是否正确
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
// 测试发现,这里return null;登录时也不会报错,说明Spring Security只对密码进行校验
return user.getUserName();
}
// 其他方法略
}
1.2.2 登录时校验密码
// 这里的username和password是从页面传入的
public String login(String username, String password) {
boolean captchaOnOff = configService.selectCaptchaOnOff();
Authentication authentication = null;
try {
// 该方法会调用到UserDetailsServiceImpl.loadUserByUsername(从数据库中查出真实
// 密码,并设置到LoginUser中,后续的过滤器会用真实的密码与这里从页面中传入的密
// 码进行对比,如果不一致,会抛出BadCredentialsException异常)
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
} catch (Exception e) {
if (e instanceof BadCredentialsException) {
// 捕获BadCredentialsException异常,抛出自定义异常
throw new UserPasswordNotMatchException();
} else {
throw new ServiceException(e.getMessage());
}
}
}
1.2.3 自定义过滤器
Spring Security框架是由很多个过滤器组成的,需要自定义一个过滤器,在每次请求时,将UsernamePasswordAuthenticationToken设置到SecurityContextHolder,供后面的过滤器认证时使用。
// OncePerRequestFilter:过滤器基类,目的是保证在任何servlet容器上,每次请求调度都能执行一次
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
// 设置authenticationToken,后面的过滤器会通过authenticationToken判断是否认证
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
chain.doFilter(request, response);
}
}
1.2.4 授权
前端可以通过菜单、按钮的有无对权限进行控制,但前端的控制安全性较低,我们可以使用Spring Security提供的@PreAuthorize对后端权限进行控制。
(1)使用Spring Security提供的权限校验方法进行权限控制
// hasAuthority是Spring Security提供的权限校验方法,该方法中会获取登录用户的权限集合,并与
// 传入的'system:config:list'进行匹配,如果匹配成功,表示登录用户拥有权限;反之,则没有权限
@PreAuthorize("hasAuthority('system:config:list')")
@GetMapping("/list")
public TableDataInfo list(SysConfig config) {
// 略
}
(2)自定义权限校验方法进行权限控制
// 自定义权限控制类
@Service("permissionService ")
public class PermissionService {
// 自定义权限校验方法(返回true:有权限,返回false:没有权限)
public boolean hasPermi(String permission) {
// 权限校验逻辑(略)
}
}
// 使用SpringEL表达式调用hasPermi方法进行权限校验
@PreAuthorize("@permissionService .hasPermi('system:config:list')")
@GetMapping("/list")
public TableDataInfo list(SysConfig config)
{
// 略
}
2. 完整的登录流程
2.1 找到login.vue中的handleLogin
2.2 找到login方法
2.3 通过"/login"找到后端的controller方法
(1)SysLoginController.login
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
// 生成经过JWT加密的token
String token = loginService.login(loginBody.getUsername(),
loginBody.getPassword(), loginBody.getCode(), loginBody.getUuid());
// 将token设置到响应体中,后续每次请求的请求头都要携带该token
// 在请求JwtAuthenticationTokenFilter过滤器中会通过JWT对该token进行解密,
// 并用解密后的token从Redis中获取loginUser,进行后续操作
ajax.put(Constants.TOKEN, token);
return ajax;
}
(2)SysLoginService.login
public String login(String username, String password, String code, String uuid)
{
boolean captchaOnOff = configService.selectCaptchaOnOff();
// 验证码开关
if (captchaOnOff)
{
// 校验验证码
validateCaptcha(username, code, uuid);
}
// 用户验证
Authentication authentication = null;
try
{
// 该方法会调用到UserDetailsServiceImpl.loadUserByUsername(从数据库中查出真实
// 密码,并设置到LoginUser中,后续的过滤器会用真实的密码与这里从页面中传入的密码进行
// 对比,如果不一致,会抛出BadCredentialsException异常)
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
// 记录登录失败日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
// 捕获BadCredentialsException,抛出自定义异常,
// 异常信息为:用户不存在/密码错误
throw new UserPasswordNotMatchException();
}
else
{
// 记录登录失败日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
// 记录登录成功日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
// 获取authentication中保存的loginUser(上面创建authentication时,
// 会调用到UserDetailsServiceImpl.loadUserByUsername,并将该方
// 法返回的UserDetail(即LoginUser)设置到authentication中)
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 记录用户的登录信息
recordLoginInfo(loginUser.getUserId());
// 生成经过JWT加密的token
return tokenService.createToken(loginUser);
}
(3)TokenService.createToken
public String createToken(LoginUser loginUser)
{
// 利用IdUtils生成token,并将生成的token设置到loginUser中
String token = IdUtils.fastUUID();
loginUser.setToken(token);
// 将用户代理信息设置到loginUser中
setUserAgent(loginUser);
// 设置/刷新token
refreshToken(loginUser);
// 创建JWT的有效载荷部分(payload部分)
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
// 通过JWT加密token
return createToken(claims);
}
(4)TokenService.refreshToken
public void refreshToken(LoginUser loginUser)
{
// 将登录时间戳、token过期时间戳设置到loginUser中
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 获取Redis的键
String userKey = getTokenKey(loginUser.getToken());
// 将loginUser设置到Redis中,过期时长是30(expireTime为30)分钟
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
(5)TokenService.createToken
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
// 设置JWT的payload
.setClaims(claims)
// 设置JWT的signature
// 通过secret加密(secret配置在application.yml中)
.signWith(SignatureAlgorithm.HS512, secret)
// 调用compact对这几部分进行处理,得到一个安全的JWT字符串
.compact();
// 返回加密后的token
return token;
}