紧接上文,我们已经完成了 SpringBoot中集成Spring Security,并且用户名帐号和密码都是从数据库中获取。但是这种方式还是不能满足现在的开发需求。
使用JWT的好处:
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDS
翻译:JSON Web Token (JWT) 是一个开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息作为 JSON 对象。 此信息可以验证和信任,因为它是数字签名的。 JWT 可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
首先不论是不是Spring Security中集成JWT,我们得先有个工具类。这个工具类的主要内容是什么呢?
创建JWT、验证JWT、 解析JWT
JwtUtils
工具类/**
* jwt工具类
*
* @author caojing
* @since 2023/6/14
*/
public class JwtUtils {
/**
* token过期时间
*/
public static final long EXPIRE = 1000 * 60 * 60 * 24;
/**
* 秘钥
*/
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
/**
* 生成token字符串的方法
*
* @param id
* @param nickname
* @return
*/
public static String getJwtToken(String id, String nickname) {
String jwtToken = Jwts.builder().setHeaderParam("typ", "JWT").setHeaderParam("alg", "HS256")
.setSubject("guli-user").setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
//设置token主体部分 ,存储用户信息
.claim("id", id)
.claim("nickname", nickname)
.signWith(SignatureAlgorithm.HS256, APP_SECRET).compact();
return jwtToken;
}
/**
* 判断token是否存在与有效
*
* @param jwtToken
* @return
*/
public static boolean checkToken(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) {
return false;
}
try {
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 判断token是否存在与有效
*
* @param request
* @return
*/
public static boolean checkToken(HttpServletRequest request) {
try {
String jwtToken = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwtToken)) {
return false;
}
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 根据token字符串获取会员id
*
* @param request
* @return
*/
public static String getUserIdByJwtToken(HttpServletRequest request) {
String jwtToken = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwtToken)) {
return "";
}
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
Claims claims = claimsJws.getBody();
return (String) claims.get("id");
}
/**
* 验证jwt
*/
public static Claims verifyJwt(String token) {
Claims claims;
try {
//得到DefaultJwtParser
claims = Jwts.parser()
//设置签名的秘钥
.setSigningKey(APP_SECRET)
.parseClaimsJws(token).getBody();
} catch (Exception e) {
e.printStackTrace();
claims = null;
}//设置需要解析的jwt
return claims;
}
}
我们可以设想下这么一个流程:
前端在请求头中设置 Authorization
参数,后台再进入到controller之前,会走一个过滤器对header中的Authorization
参数进行校验,也就是利用JWTUtils对token进行解析。
1.通过校验:模拟 spring Security 登录成功,把token值塞到一个变量里面。
2.未通过校验:继续走spring Security的验证流程(理论上会抛出异常)
注意以上我们分析的关键字:过滤器
因此,我们新建一个JwtAuthenticationTokenFilter
类继承OncePerRequestFilter
。
继承 OncePerRequestFilter
的原因:
总结来说,JwtAuthenticationTokenFilter继承OncePerRequestFilter是为了保证它在过滤器链中的每个请求中只执行一次,避免了重复处理请求的问题,确保了JWT身份验证和授权逻辑的准确性和性能。
将jwtFilter添加到Spring Security 过滤器中
JwtAuthenticationTokenFilter
类/**
* token过滤器 验证token有效性
* 判断用户是否有效走 MyUserDetailService的 loadUserByUsername 方法
*
* @author caojing
*/
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisUtils redisUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 从请求头中获取token
String authToken = request.getHeader("Authorization");
// 截取token
if (authToken != null) {
//验证token,获取token中的username
Claims claims = JwtUtils.verifyJwt(authToken);
if (claims == null) {
throw new ServletException("token异常,请重新登录");
}
//从redis 获取缓存
String redisKey = JwtUtils.getUserIdByJwtToken(request);
UserBean userBean = redisUtils.getCacheObject(redisKey);
//重新设置token的失效时间
redisUtils.setCacheObject(redisKey, userBean, 30, TimeUnit.MINUTES);
if (userBean != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//获取到值,相当于手动把session值设置到此次request中,后续就会认为已经登录,不做登录校验
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userBean, null, userBean.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
//继续下一个过滤器
chain.doFilter(request, response);
}
}
JwtAuthenticationTokenFilter
添加到ScurityConfig
类中/**
* Spring Security 配置类
*
* @author caojing
* @since 2023/6/14
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailService userDetailService;
@Autowired
private JwtAuthenticationTokenFilter JwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.addFilterBefore(JwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// auth.inMemoryAuthentication()
// .passwordEncoder(new BCryptPasswordEncoder())
// .withUser("user").password(encoder.encode("123456")).roles("USER");
auth.userDetailsService(userDetailService).passwordEncoder(new BCryptPasswordEncoder());
}
}
说明:利用addFilterBefore
方法,把jwt认证放到UsernamePasswordAuthenticationFilter
过滤器之前。为什么要放到这里,我们下一篇文章会说。
基本工作已经做完。我们还剩下一个获取token的controller。
想一想这个controller应该有什么功能?
没有使用spring Security之前,我们是不是在login获取用户输入的帐号名和密码,然后根据帐号名从数据库查询出来对应的用户信息。然后对比密码(加密后)是否正确。
使用了Spring Security之后,思考一下,哪些能用,哪些需要替换。
DaoAuthenticationProvider
进行认证。所以原先的认证需要删除替换成DaoAuthenticationProvider
认证。上面第一个问题好解决,那么第二个问题该如何实现呢?
先说结果:
使用AuthenticationManager
的authenticate
方法进行认证。
如何找到这个入口?
我们现在已知的类是DaoAuthenticationProvider
,所以先从这个类开始。先看下这个类是实现AuthenticationProvider
接口。先说一下这个接口的2个方法构成:
// ~ Methods
// ========================================================================================================
/**
* Performs authentication with the same contract as
* {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
* .
*
* @param authentication the authentication request object.
*
* @return a fully authenticated object including credentials. May return
* null
if the AuthenticationProvider
is unable to support
* authentication of the passed Authentication
object. In such a case,
* the next AuthenticationProvider
that supports the presented
* Authentication
class will be tried.
*
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
/**
* Returns true
if this AuthenticationProvider
supports the
* indicated Authentication
object.
*
* Returning true
does not guarantee an
* AuthenticationProvider
will be able to authenticate the presented
* instance of the Authentication
class. It simply indicates it can
* support closer evaluation of it. An AuthenticationProvider
can still
* return null
from the {@link #authenticate(Authentication)} method to
* indicate another AuthenticationProvider
should be tried.
*
*
* Selection of an AuthenticationProvider
capable of performing
* authentication is conducted at runtime the ProviderManager
.
*
*
* @param authentication
*
* @return true
if the implementation can more closely evaluate the
* Authentication
class presented
*/
boolean supports(Class<?> authentication);
这边重点注意2句话:
Performs authentication with the same contract as * {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
Selection of an AuthenticationProvider capable of performing authentication is conducted at runtime the ProviderManager.
这2个方法都提到了一个类:ProviderManager
。所以下一步我们看看这个类。
有点长。。。。。。。。
直接看AuthenticationManager
这个接口吧:
/**
* Processes an {@link Authentication} request.
*
* @author Ben Alex
*/
public interface AuthenticationManager {
// ~ Methods
// ========================================================================================================
/**
* Attempts to authenticate the passed {@link Authentication} object, returning a
* fully populated Authentication
object (including granted authorities)
* if successful.
*
* An AuthenticationManager
must honour the following contract concerning
* exceptions:
*
* - A {@link DisabledException} must be thrown if an account is disabled and the
*
AuthenticationManager
can test for this state.
* - A {@link LockedException} must be thrown if an account is locked and the
*
AuthenticationManager
can test for account locking.
* - A {@link BadCredentialsException} must be thrown if incorrect credentials are
* presented. Whilst the above exceptions are optional, an
*
AuthenticationManager
must always test credentials.
*
* Exceptions should be tested for and if applicable thrown in the order expressed
* above (i.e. if an account is disabled or locked, the authentication request is
* immediately rejected and the credentials testing process is not performed). This
* prevents credentials being tested against disabled or locked accounts.
*
* @param authentication the authentication request object
*
* @return a fully authenticated object including credentials
*
* @throws AuthenticationException if authentication fails
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
该类只有一个方法:authenticate
。
解释:
尝试对传递的 Authentication 对象进行身份验证,如果成功则返回一个完全填充的 Authentication 对象(包括授予的权限)。。。。。。。。。
人话:
对我们传入的Authentication
对象进行身份认证,通过以后会返回Authentication 对象。
简而言之。这个类AuthenticationManager
就是我们具体身份认证的入口了,但这是一个接口,具体的实现类是通过默认的ProviderManager
实现。
继续看ProviderManager
中的authenticate
方法:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}
......
}
我这里只贴出来部分代码:我们可以看到代码的主要结构是一个for循环。循环的内容是啥呢?是AuthenticationProvider
的实现类。循环干什么呢?
AuthenticationProvider
中的provider
方法判断是否支持验证当前的authentication
,具体行:189行
。AuthenticationProvider
的authenticate
方法,具体行:199行
解释一下第一句话:
AuthenticationProvider
和authentication
都是接口,并不是具体的实现类,所以看来比较抽象。因此,我就拿用户名密码登录方式举例。
在用户名和密码登录模式中 AuthenticationProvider
的具体实现类AbstractUserDetailsAuthenticationProvider
authentication
的具体实现类是UsernamePasswordAuthenticationToken
。那么验证身份流程就变成了
ProviderManager#authentication
-> AbstractUserDetailsAuthenticationProvider#supports
->AbstractUserDetailsAuthenticationProvider#authenticate
->return UsernamePasswordAuthenticationToken
具体时序图如下所示:
基于以上的流程,我们不难知道在login中需要调用authenticationManager#authenticate
方法进行认证了
看下配置类中继承的类WebSecurityConfigurerAdapter
其中有个方法:
/**
* Override this method to expose the {@link AuthenticationManager} from
* {@link #configure(AuthenticationManagerBuilder)} to be exposed as a Bean. For
* example:
*
*
* @Bean(name name="myAuthenticationManager")
* @Override
* public AuthenticationManager authenticationManagerBean() throws Exception {
* return super.authenticationManagerBean();
* }
*
*
* @return the {@link AuthenticationManager}
* @throws Exception
*/
public AuthenticationManager authenticationManagerBean() throws Exception {
return new AuthenticationManagerDelegator(authenticationBuilder, context);
}
这很好理解吧,不需要翻译了。
Logservice 代码如下:
/**
* 登录接口
*
* @author caojing
* @since 2023/6/15
*/
@Slf4j
@Service
public class LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisUtils redisUtils;
public ResponseBean<String> login(String username, String password) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
//这边可以获取用户信息.这里getPrincipal和 JwtAuthenticationTokenFilter类中 完成token验证之后
//new UsernamePasswordAuthenticationToken 塞进去的值
UserBean userBean = (UserBean) authentication.getPrincipal();
log.info("用户信息:{}", JSON.toJSONString(userBean));
String token = JwtUtils.getJwtToken(String.valueOf(userBean.getId()), username);
//每次登录都获取最新的值,
redisUtils.setCacheObject(String.valueOf(userBean.getId()), userBean, 30, TimeUnit.MINUTES);
return new ResponseBean<>(HttpStatus.OK.value(), "获取成功", token);
}
}
SecurityConfig
配置类增加
/**
* Spring Security 配置类
*
* @author caojing
* @since 2023/6/14
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//......................
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
访问地址:http://127.0.0.1:8889/token
测试一下token值是否有效。
先测试不带Authorization
的请求:http://127.0.0.1:8889/test
带Authorization
的请求:http://127.0.0.1:8889/test
思路:
整体思路分2个部分:
登录认证获取token
提供一个controller,将controller的地址加到spring Security 的config中不做权限控制,访问该controller,将用户名和密码的判断交给spring Security 的userDetailService处理,根据处理的返回结果决定是否生成对应的token值。
authenticationManager.authenticate()
。具体是怎么找到这个入口的,详情可以看步骤三。接口认证token值
UsernamePasswordAuthenticationFilter
。剩下的认证方式了解即可。UsernamePasswordAuthenticationToken
,其实也不是非要这个类,任何一个实现Authentication
即接口的类都可以。然后通过SecurityContextHolder.getContext().setAuthentication()
方法,将用户信息设置到SecurityContextHolder
中SecurityContextHolder.getContext().setAuthentication()
方法就可以实现登录了。或者说SecurityContextHolder
到底有什么用。SecurityMetadataSource
、GrantedAuthority
、AccessDecisionManager
)下一篇主要内容是稍微介绍下Spring Security的源码,顺带解决习题中的几个问题。
上一篇文章地址:SpringBoot2.3集成Spring Security(一)