本文为 【SpringSecurity】实现认证授权 相关知识,利用Token进行用户身份验证。
已下文章是围绕《项目》讲述的,需要源码的可私信
博主主页:一个肥鲇鱼
开发中的坏习惯:程序员的坏习惯,你占了几个!
感受 Lambda 之美:体验一下Lambda之美吧,优雅编程
Spring文件上传:Spring文件上传(详解,一文即懂)
目录
引入SpringSecurity
认证
登陆校验流程
SpringSecurity完整流程
认证流程详解
思路分析
引入所需依赖
Jwt工具类
创建UserDetailsServiceImpl类
密码存储
登陆接口
Token认证过滤器
自定义退出类
授权
授权实现
遗留问题
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。
一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是SpringSecurity作为安全框架的核心功能。
在SpringBoot项目中使用SpringSecurity我们只需要引入依赖即可实现入门案例。
org.springframework.boot
spring-boot-starter-security
引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。
必须登陆之后才能对接口进行访问。
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器
UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
FilterSecurityInterceptor:负责权限校验的过滤器。
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
①自定义登陆接口
1.1 通过ProviderManager的方法进行认证,认证通过生成Token,ProviderManager类继承AuthenticationManager接口,实现了authenticate方法
1.2 把用户信息存入redis
②自定义UserDetailsServiceImpl类
实现UserDetailsService,在这个类中查询用户信息
校验流程
定义Token认证过滤器
获取Token
解析Token获取UUID(唯一的标识)
根据UUID获取redis用户的信息
存入SecurityContextHolder
org.springframework.boot
spring-boot-starter-data-redis
com.alibaba.fastjson2
fastjson2
2.0.4
io.jsonwebtoken
jjwt
0.9.1
application.yml定义token标识
# token配置
token:
# 令牌自定义标识
header: token
# 令牌密钥
secret: yigefeinianyu
# 令牌有效期(默认30分钟)
expireTime: 30
Token的创建与验证
/**
* token验证处理
*
* @Author: 一个肥鲇鱼
*/
@Component
public class TokenService {
/**
* 令牌自定义标识
*/
@Value("${token.header}")
private String header;
/**
* 令牌秘钥
*/
@Value("${token.secret}")
private String secret;
/**
* 令牌有效期(默认30分钟)
*/
@Value("${token.expireTime}")
private int expireTime;
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
private final RedisTemplate redisTemplate;
public TokenService(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 获取用户身份信息
*
* @return 用户信息
*/
public LoginUser getLoginUser(HttpServletRequest request) {
// 获取请求携带的令牌
String token = request.getHeader(header);
if (StringUtils.isNotEmpty(token)) {
try {
Claims claims = parseToken(token);
// 解析对应的权限以及用户信息
String uuid = (String)claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = (LoginUser) redisTemplate.opsForValue().get(userKey);
return user;
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token认证失败");
}
}
return null;
}
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser) {
String token = getUUID();
loginUser.setToken(token);
refreshToken(loginUser);
Map claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map claims) {
String token = Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
/**
* 验证令牌有效期,相差不足20分钟,自动刷新缓存
*
* @param loginUser
* @return 令牌
*/
public void verifyToken(LoginUser loginUser) {
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
refreshToken(loginUser);
}
}
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisTemplate.opsForValue().set(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
* 从令牌中获取数据声明
*
* @param token 令牌
* @return 数据声明
*/
private Claims parseToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
/**
* 获取登录用户 redis key
*/
private String getTokenKey(String uuid) {
return CacheConstants.LOGIN_TOKEN_KEY + uuid;
}
/**
* 创建UUID
*
* @return
*/
public static String getUUID() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
/**
* 生成加密后的令牌秘钥 secre
*
* @return
*/
public static SecretKey generalKey() {
// byte[] encodedKey = Base64.getDecoder().decode(secret);
// SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return null;
}
/**
* 解析令牌秘钥
*
* @param token
* @return
*/
public static Claims parseJWT(String token) {
SecretKey secretKey = generalKey();
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
}
/**
* 删除用户身份信息
*/
public void delLoginUser(String token) {
if (StringUtils.isNotEmpty(token)) {
String userKey = getTokenKey(token);
redisTemplate.delete(userKey);
}
}
}
/**
* 用户验证处理
*
* @Author: 一个肥鲇鱼
*/
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final SysUserMapper sysUserMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名查询用户信息
SysUser sysUser = Optional
.ofNullable(sysUserMapper.selectOne(new LambdaQueryWrapper().eq(SysUser::getUserName, username)))
.orElseThrow(() -> new RuntimeException("用户不存在"));
if (sysUser.getStatus().equals(EnumsUserStatus.DISABLE.getCode())) {
throw new RuntimeException("账号已停用");
}
// 权限信息
List list = new ArrayList<>();
list.add("system:user:list");
// 封装成UserDetails对象返回
return new LoginUser(sysUser, list);
}
}
因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中
/**
* @Author: 一个肥鲇鱼
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private SysUser user;
/**
* 用户唯一标识
*/
private String token;
/**
* 登录时间
*/
private Long loginTime;
/**
* 过期时间
*/
private Long expireTime;
/**
* 权限信息
*/
private List permissions;
/**
* 存储SpringSecurity所需要的权限信息的集合
*/
@JSONField(serialize = false)
private List authorities;
public LoginUser(SysUser user, List permissions) {
this.user = user;
this.permissions = permissions;
}
@Override
public Collection extends GrantedAuthority> getAuthorities() {
if (authorities != null) {
return authorities;
}
// 把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
实际项目中我们不会把密码明文存储在数据库中。
我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。
我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。
我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。
密码创建和密码对比的方法
/**
* 生成BCryptPasswordEncoder密码
*
* @param password 密码
* @return 加密字符串
*/
public static String encryptPassword(String password) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.encode(password);
}
/**
* 判断密码是否相同
*
* @param rawPassword 真实密码
* @param encodedPassword 加密后字符
* @return 结果
*/
public static boolean matchesPassword(String rawPassword, String encodedPassword) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(rawPassword, encodedPassword);
}
Security配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 关闭csrf
.csrf().disable()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 允许匿名访问
.antMatchers("/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
SpringSecurity对登陆接口允许匿名访问,在登陆接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。
认证成功的话要生成一个token,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id或定义一个UUID作为key。
token会携带用户的登陆时间以及token的过期时间,如果token的有效期,相差不足20分钟,会自动刷新缓存
/**
* @Author: 一个肥鲇鱼
*/
@RestController
@RequiredArgsConstructor
public class SysLogController {
private final SysLoginService sysLoginService;
/**
* 登陆
*
* @return
*/
@RequestMapping(value = "/login", method = RequestMethod.POST)
public String login(String username, String password) {
return sysLoginService.login(username, password);
}
}
/**
* 登录校验方法
*
* @Author: 一个肥鲇鱼
*/
@Component
@RequiredArgsConstructor
public class SysLoginService {
private final TokenService tokenService;
private final AuthenticationManager authenticationManager;
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @return 结果
*/
public String login(String username, String password) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (ObjectUtils.isEmpty(authenticate)) {
throw new RuntimeException("用户名或密码错误");
}
LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
// 生成token
return tokenService.createToken(loginUser);
}
}
/**
* token过滤器 验证token有效性
*
* @Author: 一个肥鲇鱼
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final TokenService tokenService;
public JwtAuthenticationTokenFilter(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 根据token,获取用户身份信息
LoginUser loginUser = tokenService.getLoginUser(request);
if (null != loginUser) {
// 刷新token
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
// 将用户信息存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
//放行
chain.doFilter(request, response);
}
}
将token过滤器添加到过滤链中
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* token认证过滤器
*/
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 关闭csrf
.csrf().disable()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 允许匿名访问
.antMatchers("/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
/**
* 自定义退出处理类
*
* @Author: 一个肥鲇鱼
*/
@Component
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
private final TokenService tokenService;
public LogoutSuccessHandlerImpl(TokenService tokenService) {
this.tokenService = tokenService;
}
/**
* 退出处理
*
* @return
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
LoginUser loginUser = tokenService.getLoginUser(request);
if (ObjectUtils.isNotEmpty(loginUser)) {
tokenService.delLoginUser(loginUser.getToken());
}
// R.success是统一返回类,可自行定义
ServletUtils.renderString(response, JSON.toJSONString(R.success("退出成功")));
}
}
将退出类添加到过滤链中
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* token认证过滤器
*/
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/**
* 退出处理
*/
private final LogoutSuccessHandlerImpl logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 关闭csrf
.csrf().disable()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 允许匿名访问
.antMatchers("/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
// 添加Logout filter
http.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。
流程
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。
然后设置我们的资源所需要的权限即可。
SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。
需要先开启相关配置,启动类增加开启注解权限
@EnableGlobalMethodSecurity(prePostEnabled = true)
我们之前已经在UserDetailsServiceImpl类中,把用户所对应的权限信息,封装到UserDetails中,所以开启注解权限后 可以直接使用。如:
hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。
@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
public String hello(){
return "hello";
}
hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasRole('system:dept:list')")
public String hello(){
return "hello";
}
hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasAnyRole('admin','system:dept:list')")
public String hello(){
return "hello";
}
1.自定义失败处理
2.自定义权限脚校验方法
3.其他认证的方案