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中添加如下代码:

proxyTable: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    pathRewrite: {
      '^/': ''
    }
  }
}

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

@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方法实现如下:

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

@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
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
// 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
// 认证是否登录
@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
// 判断有没有权限
@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
// 退出登录成功
@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
@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
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
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
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并跳转到首页。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值