博客 / 詳情

返回

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並跳轉到首頁。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.