Spring Security+JWT+Vue实现登录权限控制(一)

登录认证

Spring Security实现登录认证主要借助其一系列过滤器链,而其中和登录最相关的就是UsernamePasswordAuthenticationFilter。但是这个过滤器只能实现基本的表单登录,表单中只能有用户名(username)和密码(password)。如果我们想自定义我们的登录表单,就必须自己实现一个过滤器,并且继承这个UsernamePasswordAuthenticationFilter

JWT

JWT,即JSON Web Token,由三部分组成:Header, Payload, Signature,并且之间由圆点(.)隔开。

JWT可以实现权限认证功能,当用户登录成功后,服务端会生成一个token传递给客户端。用户后面的每一个请求都包含了这个token,服务端解析出这个token从而判断出用户拥有的权限和能访问的资源。

JWT和之前使用的session不同,session必须保存在服务端,会增加内存开销。而且session在集群和分布式系统中需要共享,通常由Redis实现,而JWT不需要。

跨域配置

前后端分离的项目中一般都会遇到跨域的问题,我们可以通过配置来解决跨域的问题。

在Vue的index.js中添加如下代码:

js 复制代码
proxyTable: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    pathRewrite: {
      '^/': ''
    }
  }
}

而在Spring Boot的config包下添加CorsConfig配置类,代码如下:

java 复制代码
@Configuration
public class CorsConfig {
​
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        //允许源,这里允许所有源访问,实际应用会加以限制
        corsConfiguration.addAllowedOrigin("*");
        //允许所有请求头
        corsConfiguration.addAllowedHeader("*");
        //允许所有方法
        corsConfiguration.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(source);
    }
​
}

这样前后端分离的项目的跨域问题将会得以解决。

但是,在引进Spring Security后又会出现跨域问题😂,此时需要在config/SecurityConfig中再次进行跨域配置。代码在下面的后端部分呈现。

后端部分

项目主要有两类用户:普通用户和系统管理员。那么我一开始就直接简化处理了😂,将两者合并为一个类User,并且实现UserDetails接口,数据表中添加一个条目为role,类型为String,也就是角色属性,用来控制权限的。role属性我分成了两类:USERADMIN

getAuthorities方法实现如下:

java 复制代码
public Collection<? extends GrantedAuthority> getAuthorities() {
    // String[] roles = role.split(",");
    List<SimpleGrantedAuthority> authorities = new ArrayList<>();
    // for (String s : roles) {
    //    authorities.add(new SimpleGrantedAuthority(s));
    // }
    authorities.add(new SimpleGrantedAuthority(role));
    return authorities;
}

这里有点奇怪的部分是本来我参考的文章中会出现类似role属性中既有USER又有ADMIN,即ADMIN,USER,那么就需要对字符串进行分割。但是我认为完全可以简化处理,只保留一个角色即可。对于拥有多个角色的用户,可以只保留拥有最高权限的那个角色。

对于UserDetailsService接口,我们也要将其实现。这里我一开始使用了UserServiceImpl实现,但是感觉不好,所以后来使用MyUserDetailsService实现UserDetailsService

java 复制代码
@Service
public class MyUserDetailsService implements UserDetailsService{
​
    @Autowired
    private UserServiceImpl userService;
​
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.query().eq("username", username).one();
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在!");
        }
        return user;
    }
​
}

这里使用MyBatis Plus直接从数据库中查询用户,所以就不需要在mapper中写SELECT语句来操作数据库。

接下来配置各种过滤器。

LoginFilter

java 复制代码
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
​
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        if (request.getContentType().contains(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().contains(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
            Map<String, String> loginData = new HashMap<>();
            try {
                loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
            } catch (IOException e) {
            }
            String username = loginData.get("username");
            String password = loginData.get("password");
            if (username == null)
                username = "";
            if (password == null)
                password = "";
            username = username.trim();
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                    new UsernamePasswordAuthenticationToken(username, password);
            User principal = new User();
            principal.setUsername(username);
            return this.getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
        }
        else
            return super.attemptAuthentication(request, response);
    }
    
}

JWTAuthenticationFilter

java 复制代码
// JWT认证过滤器
@Component
public class JWTAuthenticationFilter extends OncePerRequestFilter {
​
    @Autowired
    private MyUserDetailsService myUserDetailsService;
​
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = request.getHeader("Authorization");
        if (StrUtil.isBlankOrUndefined(token)) {
            chain.doFilter(request, response);
            return;
        }
​
        JWT jwt = JWTUtil.parseToken(token);
        try {
            JWTValidator.of(token).validateDate(DateUtil.date());
        } catch (ValidateException exception) {
            throw new JWTException("token已过期");
        }
        String username = jwt.getPayload("username").toString();
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(username, null,
                        myUserDetailsService.loadUserByUsername(username).getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        chain.doFilter(request, response);
    }
​
}

JWTAuthenticationEntryPoint

java 复制代码
// 认证是否登录
@Component
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
​
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(response.SC_UNAUTHORIZED);
        ServletOutputStream out = response.getOutputStream();
        Result result = new Result(401, "请先登录", "");
        out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        out.flush();
        out.close();
    }
​
}

JWTAccessDeniedHandler

java 复制代码
// 判断有没有权限
@Component
public class JWTAccessDeniedHandler implements AccessDeniedHandler {
​
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(response.SC_FORBIDDEN);
        ServletOutputStream out = response.getOutputStream();
        Result result = new Result(403, accessDeniedException.getMessage(), "");
        out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        out.flush();
        out.close();
    }
​
}

JWTLogoutSuccessHandler

java 复制代码
// 退出登录成功
@Component
public class JWTLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        if (authentication != null)
            new SecurityContextLogoutHandler().logout(request, response, authentication);

        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream out = response.getOutputStream();
        Result result = new Result(200, "退出登录成功", "");
        out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        out.flush();
        out.close();
    }

}

下面需要配置Spring Security。

SecurityConfig

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyUserDetailsService myUserDetailsService;

    @Autowired
    private JWTAccessDeniedHandler jwtAccessDeniedHandler;

    @Autowired
    private JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private JWTLogoutSuccessHandler jwtLogoutSuccessHandler;

    @Autowired
    private JWTAuthenticationFilter jwtAuthenticationFilter;

    // 注入AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    // 加密,数据库中必须保存加密后的密码
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setContentType("application/json;charset=utf-8");
            ServletOutputStream out = response.getOutputStream();
            User user = (User) authentication.getPrincipal();
            // 密钥
            byte[] key = "1234567890".getBytes();
            // 使用hutool生成JWT
            String token = JWT.create()
                    .setPayload("username", user.getUsername())
                    .setExpiresAt(DateUtil.offset(DateUtil.date(), DateField.DAY_OF_MONTH, 1))
                    .setKey(key)
                    .sign();
            LoginVO loginVO = new LoginVO(user.getId(), token, user.getAvatar());
            Result result = new Result(200, "登录成功", loginVO);
            out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
            out.flush();
            out.close();
        });
        loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
            response.setContentType("application/json;charset=utf-8");
            ServletOutputStream out = response.getOutputStream();
            Result result = new Result(400, "", "");
            if (exception instanceof LockedException)
                result = new Result(400, "账户被锁定,请联系管理员!", "");
            else if (exception instanceof CredentialsExpiredException)
                result = new Result(400, "密码过期,请联系管理员!", "");
            else if (exception instanceof AccountExpiredException)
                result = new Result(400, "账户过期,请联系管理员!", "");
            else if (exception instanceof DisabledException)
                result = new Result(400, "账户被禁用,请联系管理员!", "");
            else if (exception instanceof BadCredentialsException)
                result = new Result(400, "用户名或者密码输入错误,请重新输入!", "");
            out.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
            out.flush();
            out.close();
        });
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        loginFilter.setFilterProcessesUrl("/login");
        return loginFilter;
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable() // 禁用csrf,但不安全
                .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 设置无状态
                .and()
                .authorizeRequests()
                .antMatchers("/code").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                .headers().frameOptions().disable();
        http.logout().logoutUrl("/logout").logoutSuccessHandler(jwtLogoutSuccessHandler);
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
        // 添加jwt filter
        http.addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

    // 跨域配置
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        // 允许跨域访问的 URL
        List<String> allowedOriginsUrl = new ArrayList<>();
        allowedOriginsUrl.add("http://localhost:8080");
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        // 设置允许跨域访问的 URL
        config.setAllowedOrigins(allowedOriginsUrl);
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
        hierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER"); // 角色继承
        return hierarchy;
    }

}

这里使用的hasRole,所以数据库中role属性需要加ROLE_前缀,但是如果使用hasAuthority,就不需要加上ROLE_前缀。authority是权限,role则是角色,角色是权限的集合,但在实际使用中,这两者的区别不大,可以混用。

对于以上代码,我们只是通过HttpSecurity进行用户权限配置,没有实现动态权限配置,不够灵活,在之后的文章中,我会改进这一点。

前端部分

前端我通过Vue来实现,为了简化处理,我将所有页面都归为静态页面,只不过有些页面需要确认权限。

router/index.js

js 复制代码
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export const constantRouter = [
  {
    path: '/',
    name: 'Default',
    redirect: '/home',
    component: () => import('@/views/home')
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login')
  },
  {
    path: '/home',
    name: 'Home',
    component: () => import('@/views/home'),
    redirect: '/index',
    children: [
      {
        path: '/index',
        name: 'Index',
        component: () => import('@/views/home/index')
      },
        path: '/user',
        name: 'User',
        component: () => import('@/views/user/index')
      }
    ]
  },
  {
    path: '/manage',
    name: 'Manage',
    component: () => import("@/views/admin/manage"),
  },
  {
    path: '/*',
    component: () => import('@/views/error/404'),
  }
]

export default new Router({
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRouter
})

main.js

js 复制代码
router.beforeEach((to, from, next) => {
  if (to.path === '/login')
    next()
  else {
    // 有token
    if (store.getters.token)
      next()
    // 没有token
    else {
      next({
        path: '/login',
        query: {redirect: to.fullPath }
      })
    }
  }
})

utils/request.js

js 复制代码
import axios from 'axios'
import store from '@/store'
import { Message, MessageBox } from 'element-ui'
import router from '../router'

axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
  baseURL: process.env.BASE_API,
  // 超时
  timeout: 10000
})

// request请求拦截
service.interceptors.request.use(
  config => {
    if (store.getters.token) {
      config.headers['Authorization'] = store.getters.token
    }
    return config
  },
  error => {
    console.log(error)
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(response => {
  const res = response.data
  const code = res.code
  if (code === 200)
    return res
  else
    return Promise.reject('error')
},
  error => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          MessageBox({title: '提示', message: '请先登录!', type: 'error',
            callback: (action)=>{
              if (action === 'confirm')
                router.replace({path: '/index'})
            }})
          break;
        case 403:
          MessageBox({title: '提示', message: '没有权限,请联系管理员!', type: 'error',
            callback: (action)=>{
              if (action === 'confirm')
                router.replace({path: '/index'})
            }})
          break;
      }
    }
    else
      return Promise.reject(error)
  }
)

export default service

总结

上面的代码已经能够实现基本的登录认证和权限控制,首先判断用户是否登录,登录成功后分配权限。用户每次请求都会携带token,有权限可以直接访问页面,而没有权限则会显示403并跳转到首页。

相关推荐
biyezuopinvip5 小时前
基于Spring Boot的企业网盘的设计与实现(任务书)
java·spring boot·后端·vue·ssm·任务书·企业网盘的设计与实现
我是伪码农5 小时前
Vue 智慧商城项目
前端·javascript·vue.js
JavaGuide6 小时前
一款悄然崛起的国产规则引擎,让业务编排效率提升 10 倍!
java·spring boot
figo10tf6 小时前
Spring Boot项目集成Redisson 原始依赖与 Spring Boot Starter 的流程
java·spring boot·后端
zhangyi_viva6 小时前
Spring Boot(七):Swagger 接口文档
java·spring boot·后端
小书包酱6 小时前
在 VS Code中,vue2-vuex 使用终于有体验感增强的插件了。
vue.js·vuex
橙露6 小时前
Spring Boot 核心原理:自动配置机制与自定义 Starter 开发
java·数据库·spring boot
程序员敲代码吗6 小时前
Spring Boot与Tomcat整合的内部机制与优化
spring boot·后端·tomcat
NuageL7 小时前
原始Json字符串转化为Java对象列表/把中文键名变成英文键名
java·spring boot·json
Zhencode7 小时前
Vue3 响应式依赖收集与更新之effect
前端·vue.js