Spring Security + JWT 实现登录认证和权限控制
准备步骤
准备好一些常用的工具类,比如jwtUtil,redisUtil等。引入数据库,mybatis等,配置好controller,service,mapper,保证能够正常的数据请求。这里就省略了
1. 实体类User
package com.example.logindemo.domain.entity;
import lombok.Data;
import java.io.Serializable;
@Data
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Integer id;
private String username;
private String account; //账号。我是用的这个登录,没用username
private String password;
private String empCode;
private Integer sex;
private Integer age;
private String role;
}
2. LoginUser类
由于Security默认需要一个UserDetails,所以单独用了这个LoginUser
类来实现UserDetails
接口,把我们自己的User
放进来
/**
* @title: LoginUser
* @Author DengMj
* @Date: 2024/5/6 11:32
* @Version 1.0
*/
@Data
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
/**
* @return 返回用户权限,我这里直接放的角色
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + this.user.getRole()));
return authorities;
}
@Override
public String getPassword() {
return this.user.getPassword();
}
@Override
public String getUsername() {
return this.user.getAccount();
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
3. 重写loadUserByUsername方法
让我们的UserService
接口去继承UserDetailsService
类
public interface UserService extends UserDetailsService {
User getUserById(int id);
}
UserDetailsService
类有一个loadUserByUsername
方法需要在我们的UserServiceImpl
中重写
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public User getUserById(int id) {
return userMapper.getUserById(id);
}
//这里需要返回一个UserDetails,所以我们定义了LoginUser类,当然也可以直接用User类去实现UserDetails接口
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
User user = userMapper.getUserByAccount(account);
if (null == user){
throw new UsernameNotFoundException("账号不存在!");
}
return new LoginUser(user);
}
}
4. 登录页面login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<h2>Login</h2>
<form action="/login" method="post">
<div><label>account:<input type="text" name="account"></label></div>
<div><label>password:<input type="password" name="password"></label></div>
<div><input type="submit"></div>
</form>
</body>
</html>
关键步骤
1.SecurityConfig配置类
在这里需要重写两个configure
方法
void configure(AuthenticationManagerBuilder auth)
,用来自定义登录验证的逻辑void configure(HttpSecurity http)
,用来配置请求拦截等策略
/**
* @title: SecrityConfig
* @Author DengMj
* @Date: 2024/5/5 16:09
* @Version 1.0
*/
@Slf4j
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启权限注解,默认是关闭的
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserAuthenticationProvider userAuthenticationProvider;
@Autowired
private UserLoginSuccessHandler userLoginSuccessHandler;
@Autowired
private UserLoginFailureHandler userLoginFailureHandler;
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private UserAuthenticationEntryPointHandler userAuthenticationEntryPointHandler;
@Autowired
private UserAuthAccessDeniedHandler userAuthAccessDeniedHandler;
@Autowired
private UserPermissionEvaluator userPermissionEvaluator;
//自定义的登陆验证逻辑
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(userAuthenticationProvider);
}
//注入自定义PermissionEvaluator,使用hasPermission()的时候才需要
@Bean
public DefaultWebSecurityExpressionHandler userSecurityExpressionHandler(){
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setPermissionEvaluator(userPermissionEvaluator);
return handler;
}
//登录拦截配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过session获取securityContext,通过每个请求中携带的Token来识别用户
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().cors()
.and()
//所有URL都需要认证
.authorizeRequests().antMatchers("/**").authenticated()
.and()
//未登录处理类
.httpBasic().authenticationEntryPoint(userAuthenticationEntryPointHandler)
.and()
//对于登录接口允许访问
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/login").permitAll()
//自定义登录用户名为account,默认的是username
.usernameParameter("account")
.passwordParameter("password")
//登录认证成功handler
.successHandler(userLoginSuccessHandler)
//登录认证失败handler
.failureHandler(userLoginFailureHandler)
.and()
//配置登出地址
.logout().logoutUrl("/logout")
//成功登出
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
.and()
//用户无权限handler
.exceptionHandling().accessDeniedHandler(userAuthAccessDeniedHandler)
.and()
//配置认证过滤器
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
//禁用缓存
.headers().cacheControl();
}
}
以下是几个关键的自定义的类,我们需要一一实现
2. UserAuthenticationProvider实现自定义的登录逻辑
自定义UserAuthenticationProvider
类实现AuthenticationProvider
接口,用户发起登录请求后会执行authenticate()
方法来进行登录验证。
该方法会返回一个UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)
类型的结果,该类的父类AbstractAuthenticationToken
实现了Authentication
接口,用来封装用户的认证信息。
/**
* 自定义登录验证逻辑
*
* @title: UserAuthenticationProvider
* @Author DengMj
* @Date: 2024/5/5 17:14
* @Version 1.0
*/
@Component
public class UserAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserService userService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String account = (String) authentication.getPrincipal(); //登录请求中的账号
String password = (String) authentication.getCredentials(); //登录密码
LoginUser loginUser = (LoginUser) userService.loadUserByUsername(account);
if (null == loginUser) {
throw new UsernameNotFoundException("账号不存在!");
}
//这里直接用了MD5加密,没用Security中的passwordEncoder
if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(loginUser.getPassword()))
throw new BadCredentialsException("密码错误!");
//UsernamePasswordAuthenticationToken的第三个参数需要Set<GrantedAuthority>类型的。
//所以需要把我们的角色信息封装进去
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority(loginUser.getUser().getRole()));
//返回封装好的用户认证信息
return new UsernamePasswordAuthenticationToken(account, password, authorities);
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
以下是UsernamePasswordAuthenticationToken
类的构造函数,其中principle
表示用户信息(比如用户名),credentials
是用户凭证(比如密码),authorities权限信息。
登录后的用户认证信息默认会被保存在SecurityContext
中,每个作用域是HTTP session,所以一次登录之后,该会话中的请求都可以通过上下文信息中的用户认证信息认证成功。
但是我们一般都会使用无状态的RESTful API,不依赖于服务器端的会话来维持用户的认证状态,而是通过每个请求中携带的Token来识别用户。所以我们不想要Spring Security
维护HTTP会话,即不使用HTTP会话来存储安全上下文信息,例如认证信息 。所以需要在SecurityConfig
中做以下配置:
3. JwtAuthenticationTokenFilter过滤器
自定义一个JwtAuthenticationTokenFilter
类去实现OncePerRequestFilter
接口,这样每次有请求进来的时候,都会先去执行doFilterInternal()
方法。
/**
* @title: JwtAuthenticationTokenFilter
* @Author DengMj
* @Date: 2024/5/6 11:04
* @Version 1.0
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
RedisUtil redisUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
//没有token
if (StringUtils.isEmpty(token)){
//放行,交个后续的其他过滤器处理
filterChain.doFilter(request, response);
return;
}
//验证token
if (!JwtUtils.verify(token)){
throw new RemoteException("token非法");
}
//解析token拿到User信息
User userByToken = JwtUtils.getUserByToken(token);
String redisKey = "token:" + userByToken.getAccount();
JSONObject jsonObject = (JSONObject) redisUtil.get(redisKey);
User user = jsonObject.toJavaObject(User.class);
//redis中没有记录,说明用户没有登录,或者登录过期了
if (null == user){
throw new RemoteException("用户未登录");
}
//用户的权限信息
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority(user.getRole()));
//在上下文中保存用户认证信息
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(new LoginUser(user), token, authorities);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
将过滤器添加到配置类的HttpSecurity配置中
4. 自定义登录相关的处理类
在SecurityConfig
配置类中,void configure(HttpSecurity http)
方法可以指定登录认证后各种情况的handler,接下来我们挨个实现这些类。
1.UserLoginSuccessHandler
如果通过了UserAuthenticationProvider
的登录逻辑验证,那么就会执行该类中的onAuthenticationSuccess()
方法,我们可以在这里将用户信息存入到Redis中,并且返回jwt签署的token。
/**
* @title: UserLoginSuccessHandler 登录成功处理类
* @Author DengMj
* @Date: 2024/5/5 19:51
* @Version 1.0
*/
@Slf4j
@Component
public class UserLoginSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
UserService userService;
@Autowired
RedisUtil redisUtil;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String account = (String) authentication.getPrincipal();
LoginUser loginUser = (LoginUser) userService.loadUserByUsername(account);
User user = loginUser.getUser();
String token = JwtUtils.sign(user);
//把用户信息存入到Redis中
String redisKey = "token:" + user.getAccount();
redisUtil.set(redisKey, user);
redisUtil.expire(redisKey, TimeUnit.HOURS.toMillis(2));
log.info(APIResult.newSuccessResult(token));
//返回token
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().println(APIResult.newSuccessResult(token));
}
}
2.UserLoginFailureHandler
如果登录失败就会走这个handler,可以根据抛出的不同的异常来判定登陆失败的原因并通过response返回。
/**
* 登录失败处理类
* @title: UserLoginFailureHandler
* @Author DengMj
* @Date: 2024/5/6 15:39
* @Version 1.0
*/
@Slf4j
@Component
public class UserLoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
log.info("登陆失败:{}", e.getMessage());
if (e instanceof UsernameNotFoundException)
response.getWriter().println(APIResult.newFailResult("账号不存在!"));
else if (e instanceof LockedException)
response.getWriter().println(APIResult.newFailResult("账号被冻结!"));
else if (e instanceof BadCredentialsException)
response.getWriter().println(APIResult.newFailResult("密码错误!"));
else
response.getWriter().println(APIResult.newFailResult("登陆失败!"));
}
}
3.UserAuthenticationEntryPointHandler
Spring Security 没有登录时,会被 UsernamePasswordAuthenticationFilter
拦截器拦截,它是处理表单登录的默认拦截器。如果没有登录就尝试访问受保护的资源,Spring Security 会返回登录页面或者返回401 Unauthorized错误,具体取决于配置。
如果想自定义未登录的处理方式,可以通过实现 AuthenticationEntryPoint
接口来定制。
/**
* 用户未登录处理类
* @title: UserAuthenticationEntryPointHandler
* @Author DengMj
* @Date: 2024/5/6 15:52
* @Version 1.0
*/
@Component
public class UserAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().println(APIResult.newFailResult(401, "未登录!"));
}
}
5. 认证流程总结
到这里登录认证就已经实现了,总结一下整个流程就是:
-
用户发起登录请求
-
请求被
JwtAuthenticationTokenFilter
过滤器拦截,执行doFilterInternal
方法,此时发现token为null,因此放行给后续过滤器处理 -
后续过滤器发现这是一个地址为
/login
的登录请求,由于我们在配置类的configure
方法中配置了允许登录接口访问,因此该请求不会被拦截 -
然后会调用
UserAuthenticationProvider
中的authenticate
方法进行登录验证 -
根据登录验证的结果判断是调用
UserLoginFailureHandler
还是UserLoginSuccessHandler
-
response返回结果,整个登录认证的过程就结束了
大概的执行流程就是这样,debug得出来的这个执行流程,具体底层的东西还不是特别清楚。
6. 在请求方法上添加注解@PreAuthorize
使用注解@PreAuthorize(“hasAuthority(‘admin’)”)表明该请求需要admin权限
@PreAuthorize("hasAuthority('admin')")
@GetMapping("/getUserById/{id}")
// @PreAuthorize("hasPermission('/user', 'admin')")
public String getUserById(@PathVariable int id){
User userById = userService.getUserById(id);
return APIResult.newSuccessResult(userById);
}
@PreAuthorize
注解可选的参数包括这些,其中hasRole()
和hasAuthority()
方法是差不多的,只是一个前缀ROLE_的区别,具体的看后面会讲到。
如果使用hasPermission()
可以自己定义UserPermissionEvaluator
类通过实现PermissionEvaluator
接口来自定义鉴权逻辑
UserPermissionEvaluator
/**
* @title: UserPermissionEvaluator
* @Author DengMj
* @Date: 2024/5/6 16:39
* @Version 1.0
*/
@Component
public class UserPermissionEvaluator implements PermissionEvaluator {
/**
* @param authentication 用户信息
* @param targetUrl 请求路径
* @param permission 需要的权限
* @return
*/
@Override
public boolean hasPermission(Authentication authentication, Object targetUrl, Object permission) {
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
return loginUser.getAuthorities().contains(permission);
}
@Override
public boolean hasPermission(Authentication authentication, Serializable serializable, String s, Object o) {
return false;
}
}
7. UserAuthAccessDeniedHandler
自定义UserAuthAccessDeniedHandler
类用来处理当访问被拒绝时的逻辑,并将其加入到配置类中
/**
* 暂无权限处理类
* @title: UserAuthAccessDeniedHandler
* @Author DengMj
* @Date: 2024/5/6 15:54
* @Version 1.0
*/
@Component
public class UserAuthAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().println(APIResult.newFailResult(403, "暂无权限"));
}
}
8. 鉴权流程总结
这样我们的鉴权功能就实现了,总结一下实现的流程:
- jwt过滤器从请求中拿到token,解析出用户信息,每个用户信息中都带有该用户的角色信息(role字段),将用户认证信息保存在上下文中
- 根据我们在controller方法上标注的注解
@PreAuthorize("hasAuthority('admin')")
,会执行源码中的这段代码,实现鉴权
- 如果没有权限则会通过
UserAuthAccessDeniedHandler
来处理访问被拒绝时的逻辑
以上只是简单的实现了一下Security的登录认证和鉴权相关的操作,底层原理还需要再花时间学习~