Security+JWT 实现前后端分离用户登录、授权

前言

学校让做一个项目,我负责后端部分,首先我做了登录认证部分。使用JWT+SpringSecurity,同时完成了token续签。有同样需求的小伙伴可以参考我的实现,有不足的地方还请大佬指正!

登录认证流程

登录成功获取使用jwt加密的令牌,每次请求在头部携带这个令牌,后端进行认证。

SpringSecurity

过滤链

SpringSecurity核心就是授权与认证,首要解决的问题是什么样的接口需要用户登录访问,什么样的接口需要具有相应权限才可访问。
在这里插入图片描述
以上是SpringSecurity过滤链
1、SecurityContextPersistenceFilter 会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后
把它设置给 SecurityContextHolder。
2、UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码。登录成功或失败后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandle。
我是把用户登录信息存放到redis中的,所以我们需要在UsernamePasswordAuthenticationFilter之前就获取到当前用户并且根据当前登录用户创建UsernamePasswordAuthenticationToken,这样SpringSecurity就会认为我是登录过的。没有UsernamePasswordAuthenticationToken SpringSecurity会认为是访客,那么只能访问不进行授权的接口。

此时需要一个JWT的过滤器

JWT过滤器

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private TokenService tokenService;

    @Value("${token.header}")
    private String header;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
            //获取当前登录用户
        LoginUser loginUser = tokenService.getLoginUser(request);
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (ObjectUtil.isNotNull(loginUser) && ObjectUtil.isNull(authentication)) {
        //检查token是否需要续期
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

其中获取当前用户有很多方法,我是获取http请求头的token,得到jwt后进行获取获取当前用户登录id,进而通过redis,得到用户信息。

private String getToken(HttpServletRequest request) {
       return request.getHeader(header);
   }
 public LoginUser getLoginUser(HttpServletRequest request) throws IOException {
      // 获取请求携带的令牌
      String token = getToken(request);
      if (StrUtil.isNotEmpty(token)) {
          try {
              String uuid = verityToken(token, RedisKeyConstant.LOGIN_USER_KEY, String.class);
              String userKey = getTokenKey(uuid);
              return redisCache.getCacheObject(userKey);
          } catch (Exception e) {
              System.out.println("篡改身份信息");
          }
      }
      return null;
  }

jwt 的载体部分实际上base64,解码就可以得到详细的用户信息,所以一定不能在jwt中放入敏感信息,我这里只放了当前登录的uuid,然后用uuid作为redis的key,jwt在这里起到的作用只是防止不法分子恶意篡改数据。jwt还有一个缺点就是不可以为其设置失效,它只是一个单纯的签名令牌,所以借助了redis对登录用户进行存储。

登录

登录要做的事情就是验证用户名和密码,获取用户的权限。存储当前登录用户到redis。SpringSecurity有自己的登录对象,我们要用重写需要实现UserDetails接口

@ToString
public class LoginUser implements UserDetails {
    @JsonIgnore
    private static final long serialVersionUID = 1L;

    public LoginUser() {
    }

    /**
     * 用户ID
     */
    private Long userId;

    /**
     * 用户名
     */
    private String userName;

    /**
     * 密码
     */
    @JsonIgnore
    private String password;

    /**
     * 登录时间
     */
    private Long loginTime;

    /**
     * 登录用户
     */
    private User user;

    /**
     * 用户登录标识
     */
    private String token;

    /**
     * 过期时间
     */
    private Long expireTime;

    /**
     * 权限列表
     */
    private Set<String> permissions;

    public LoginUser(Long userId, String userName, String password, User user, Set<String> permissions) {
        this.userId = userId;
        this.userName = userName;
        this.password = password;
        this.user = user;
        this.permissions = permissions;
    }

    public User getUser() {
        return user;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public Long getLoginTime() {
        return loginTime;
    }

    public void setLoginTime(Long loginTime) {
        this.loginTime = loginTime;
    }

    public Long getExpireTime() {
        return expireTime;
    }

    public void setExpireTime(Long expireTime) {
        this.expireTime = expireTime;
    }

    public Set<String> getPermissions() {
        return permissions;
    }

    public void setPermissions(Set<String> permissions) {
        this.permissions = permissions;
    }

    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }


    @JsonIgnore
    @Override
    public String getPassword() {
        return this.password;
    }


    @JsonIgnore
    @Override
    public String getUsername() {
        return this.userName;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }
}

如何进行加载对象我们需要实现UserDetailsService接口进行重写

@Component
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MenuService menuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserName, username);
        User user = userMapper.selectOne(queryWrapper);
        if (user == null)
            throw new GlobalException(RespBeanEnum.USER_ACCOUNT_NOT_EXIST);
        Set<String> permissions = null;
        if (user.getRoleId() == 1)
            permissions = menuService.getAllPerms();
        else
            permissions = menuService.getPermsByRoleId(user.getRoleId());
        return new LoginUser(user.getUserId(), user.getUserName(), user.getPassword(), user, permissions);
    }
}

其中也获得了这个用户的权限信息存入permissions集合

登录逻辑

@Service
public class LoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private TokenService tokenService;

    public String login(String username, String password) {
        // 用户验证
        Authentication authentication = null;
        try {
            authentication =
                    authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            throw new LoginException((AuthenticationException) e);
        }
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        return tokenService.createToken(loginUser);
    }
}

通过authenticationManager对当前用户名和密码进行认证,符合条件可以通过authentication.getPrincipal()获取当前对象,而这个调用我们刚刚loadUserByUsername中的对象,把这个对象存到redis里面。此时登录就完成了。

注销用户

@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    @Autowired
    private TokenService tokenService;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (ObjectUtil.isNotNull(loginUser)) {
            tokenService.delLoginUser(loginUser.getToken());
        }
        ServletUtil.writeJson(request,response, RespBean.success("注销成功"));
    }
}

权限认证

这个系统中我没有使用SpringSecurity自带的角色和权限认证,而是自己写了个权限认证服务。使用SpringSecurity的@PreAuthorize()注解在需要进行权限认证的接口上面

@PostMapping("/register")
    @PreAuthorize("@ps.hasPermit('user:add')")
    public RespBean register(@Validated @RequestBody User user) {
        user.setPassword(SecurityUtil.encryptPassword("123456"));
        userMapper.insert(user);
        return RespBean.success();
    }

比如这样,就表示这个接口必须用有user:add权限的用户才可以访问

认证服务,其实就是查看用户权限那个集合中没有没这个权限字段

@Service("ps")
public class PermissionService {

    //权限分割
    private static final String PERMISSION_DELIMITER = ",";

    public boolean hasPermit(String permit) {
        LoginUser loginUser = SecurityUtil.getLoginUser();
        if (loginUser == null)
            return false;
        Set<String> permissions = loginUser.getPermissions();
        if (permissions == null)
            return false;
        return permissions.contains(permit);
    }

    public boolean hasAnyPermit(String permits) {
        LoginUser loginUser = SecurityUtil.getLoginUser();
        if (StrUtil.isEmpty(permits))
            return false;
        Set<String> permissionSet = loginUser.getPermissions();
        if (permissionSet == null)
            return false;
        String[] permissions = getPermits(permits);
        for (String permission : permissions) {
            if (permissionSet.contains(permission))
                return true;
        }
        return false;
    }

    public String[] getPermits(String permits) {
        return permits.trim().split(PERMISSION_DELIMITER);
    }

}

后续处理

以上就是SpringSecurity整个授权与认证部分,那么接下来应该处理一些异常,如用户没有登录,用户没有权限。可以自定义其方法

用户未登录处理AuthenticationEntryPoint

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        RespBean error = RespBean.error(RespBeanEnum.USER_LOGIN_EXPIRE);
        ServletUtil.writeJson(request, response, error);
    }
}

用户没有权限AccessDeniedHandler

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        RespBean error = RespBean.error(RespBeanEnum.NO_PERMISSION);
        ServletUtil.writeJson(request, response, error);
    }
}

SpringSecurity 总配置

接下来需要将上面实现的配置到SpringSecurity中,让SpringSecurity去使用

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    //用户未登录处理
    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    //用户没有权限
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/login", "/logout").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
        ;
        http.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
    }
}

PasswordEncoder是认证用户密码的方式,所以在存数据库的时候也要用和SpringSecurity中相同的加密方式进行存储,这样也保重了数据库密码安全。

token续签

我们在系统使用中,用户可能一直会使用系统,那么在使用过程中token过期导致用户登出会造成不好的用户体验。那么可以在快要过期的时候就延长下token时长。我的loginUser中存放这个登录时间和过期时间。请求的时候如果过期时间减当前时间小于20分钟,那么我会重新更新登录时间和过期时间,并把更新的数据重新存到redis里面。

public void verifyToken(LoginUser loginUser) {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
            refreshToken(loginUser);
        }
    }
     public void refreshToken(LoginUser loginUser) {
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        String userKey = getTokenKey(loginUser.getToken());
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
    }

以上只是一种思路,续签的方法还有很多!

总结

本文重点是提供权限认证的思路,具体如何获取用户权限具体业务具体分析,动态权限可以考虑RBAC模式实现,这是另外要讨论的了。权限认证做完,可以再考虑下接口请求安全性,如何防止恶意用户篡改请求数据,使用其他已经签发好的jwt重复提交数据,重放攻击等。解决上面的问题可以用使用哈希算法使用前后端商定好的公钥对时间戳,用户token,请求参数进行运算,后端将前端传来的密文进行比对。本文就写到这里,有不足的地方还请大佬指正!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值