Spring Security + jwt 實現安全控制

權限系統是每一個系統必不可少的一部分,咱們能夠本身實現根據本身的需求採用不一樣的技術方案。最近在咱們的管理後臺尚使用了Spring Security + JWT實現了後臺的權限系統,包括用戶登陸,角色分配,鑑權與受權。css

理解權限框架本質

有哪些技術方案? 業內通用的作法有Shiro,Spring Security,還有不少公司本身實現的基於url攔截的權限框架。從我的使用體驗上來講,有好用的輪子就應該選擇用通過不少人驗證過的輪子。而不是本身沉迷於簡單的增刪改,時間應該花在研究security的原理,代碼組織架構上,由於我也見過幾個項目本身手寫的權限框架,並無用的很流暢,反而老是在一些url匹配不夠通用上問題頻出。 那麼權限框架的本質是什麼? 對,就是匹配邏輯。舉個簡單例子,網站用戶A擁有權限標識:"user_add","coupon_delete","coupon_all",接收到request請求後,判斷此請求須要的權限標識是否匹配。權限標識能夠是:menu_url,menu_code,role_code等等,咱們能夠選擇系統中變更頻率小的變量來作角色標識。由於這個權限標識只能硬編碼或者ant風格匹配在目標資源上。舉個例子:假如你的系統角色固定,那就用角色code做權限標識,如果菜單基本固定,就用菜單url作標識。後面會具體講到html

用戶登陸的邏輯和jwt

用戶究竟是怎麼登陸的? 這個問題對於初級工程師來講會很迷惑,曾經也經歷過。因此簡單說明下。在通常的web軟件開發中,開發者不須要關注會話這件事情,由於tomcat容器自動幫咱們管理的會話session,他的流程是這樣的,用戶訪問服務,服務端生成session會話,而且把sessionId回寫到瀏覽期的cookie中,瀏覽器後面的每次請求就會攜帶上這個sessionId。服務端就能標識這個用戶了,至於登錄鑑權的邏輯都是基於你能惟一標識當前的用戶來作的。通用的作法是,用戶成功登錄後,服務端會把用戶信息存放在sessionId標識的session中。隨着用戶體量增多,在分佈式的環境下通常的作法是session共享,或者採用redis接替tomcat管理session會話的方案。 爲何要用jwt? 全程是json web token,關於jwt是什麼,能夠參考阮一峯的文章:JSON Web Token 入門教程。使用了jwt後,咱們徹底把登錄信息存放在客戶端,每次認證都是由客戶端帶着鑑權參數過來。具體的邏輯是服務端生成token,包含token有效期,存放的鑑權信息等,下發給客戶端。客戶端自放在本地。服務端就能夠提供無狀態的服務了,很是方便擴展。前端

實際案例

導入依賴git

<!-- 基於spring boot  -->
 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.2.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
複製代碼

配置security程序員

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

    /**
     * 讀取忽略的配置文件
     */
    @Autowired
    private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;

    /**
     * 未攜帶token的異常處理
     */
    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    /**
     * 業務的用戶密碼驗證
     */
    @Autowired
    private JwtUserDetailsService jwtUserDetailsService;

    /**
     * 自定義基於JWT的安全過濾器
     */
    @Autowired
    private JwtAuthorizationTokenFilter authenticationTokenFilter;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 配置基於數據庫的用戶密碼查詢  密碼使用security自帶的BCryptEncoder(結合了隨機鹽和加密算法)
        auth.userDetailsService(jwtUserDetailsService)
                .passwordEncoder(passwordEncoder());
    }

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

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();

        // 【1】受權異常及不建立會話(不使用session)
        http.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        //容許不登陸訪問的接口
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();

        // 【2】 從配置文件讀取url
        registry.antMatchers(HttpMethod.OPTIONS, "/**").anonymous();
        filterIgnorePropertiesConfig.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

        //須要登陸才容許訪問
        filterIgnorePropertiesConfig.getAuthenticates().forEach(url -> registry.antMatchers(url).authenticated());

        //其它的嚴格控制權限,必須權限擁有的菜單中對應的api_url才容許訪問 【3】 權限控制
        //registry.anyRequest().access("@permissionService.hasPermission(request,authentication)");
        registry.anyRequest().authenticated();

        // 把token攔截器配置在security 用戶名和密碼攔截器以前  【4】 從token解析的邏輯
        http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // AuthenticationTokenFilter will ignore the below paths
        web.ignoring()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                );
    }
}
複製代碼
處理配置文件
@Data
@Configuration
@RefreshScope
@ConditionalOnExpression("!'${ignore}'.isEmpty()") 
@ConfigurationProperties(prefix = "ignore")
public class FilterIgnorePropertiesConfig {

    private List<String> urls = new ArrayList<>();

    private List<String> authenticates = new ArrayList<>();

}
複製代碼

application.ymlgithub

ignore:
  urls:
  - /auth/**
  - /act/**
  - /druid/*
  - /*/user/login
複製代碼

anonymous:都支持訪問 permitAll():不登錄也能訪問 authenticated():登錄就能訪問 access():嚴格控制權限web

token攔截器

攔截器主要作了這麼幾件事:redis

1.從請求頭裏面獲取token 2.解析token裏面存放的用戶信息 3.用戶信息不爲空,且當前請求SecurityContextHolder(默認的實現是ThreadLocal)中的用戶信息爲空,就設置進去。 3.1用redis標記了token是不是用戶手動過時掉的,由於token自己存放了過時時間 沒法修改。 3.2根據3中簡要的用戶信息查詢所有用戶信息,包括角色,菜單等。若是你足夠信任token,也能夠省略這裏查詢數據庫。算法

@Slf4j
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;

    private OrRequestMatcher orRequestMatcher;

    @Autowired
    private UserDetailsService userDetailsService;

    private final JwtTokenUtil jwtTokenUtil;

    private final String tokenHeader;

    private int expiration;

    @Autowired
    private RedisManager redisManager;

    @PostConstruct
    public void init() {
// 初始化忽略的url不走過此濾器
        List<RequestMatcher> matchers = filterIgnorePropertiesConfig.getUrls().stream()
                .map(url -> new AntPathRequestMatcher(url))
                .collect(Collectors.toList());
        orRequestMatcher = new OrRequestMatcher(matchers);
    }

    public JwtAuthorizationTokenFilter(JwtTokenUtil jwtTokenUtil, @Value("${jwt.header}") String tokenHeader, @Value("${jwt.expiration}") Long expire) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.tokenHeader = tokenHeader;
        this.expiration = (int) (expire / 1000);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {

        String requestURI = request.getRequestURI();
        log.debug("processing authentication for '{}'", requestURI);
        final String requestHeader = request.getHeader(this.tokenHeader);

        JwtUser jwtUser = null;
        String authToken = null;
        if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
            authToken = requestHeader.substring(7);
            try {
                jwtUser = jwtTokenUtil.getJwtUserFromToken(authToken);
            } catch (ExpiredJwtException e) {
                // token 過時
                throw new AccountExpiredException("登錄狀態已過時");
            } catch (MalformedJwtException e) {
                log.info("解析前端傳過來的Authentication錯誤,但不影響業務邏輯!token:{}", requestHeader);
            } catch (Exception e) {
                log.info("JwtAuthorizationTokenFilter處理異常!{}", e.getMessage());
            }
        }
        log.debug("checking authentication for user '{}'", jwtUser);

        //生成jwt的token的過時時間是一天,而這裏控制實際過時時間是兩個小時(application.yml配置的過時時間)
        if (jwtUser != null && jwtUser.getUsername() != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            if (redisManager.exists(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken)) {
                redisManager.expire(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken, expiration);
            } else {
                throw new AccountExpiredException("登陸信息已通過期或已經退出登陸,請從新登陸!");
            }

            UserDetails user = userDetailsService.loadUserByUsername(jwtUser.getUsername());
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            log.debug("authorizated user '{}', setting security context", user.getUsername());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    /**
     * 能夠重寫
     * @param request
     * @return 返回爲true時,則不過濾即不會執行doFilterInternal
     * @throws ServletException
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return orRequestMatcher.matches(request);
    }
}
複製代碼
從持久層查詢用戶

1.把用戶的權限標識封裝到GrantedAuthority對象,這是security封裝的權限頂級接口。 2.檢驗菜單權限的時候就會經過這裏封裝的權限標識來比對。 3.關於權限標識的選取上文有提到,儘可能選擇不容易變更的變量(角色Code|菜單Code|菜單path)。 4.這個對象就是放在線程變量的用戶對象,serurity的註解也會從這裏取出權限標識來比對spring

@Primary
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class JwtUserDetailsService implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username){

        // 根據登錄的用戶名查詢用戶相關的信息
        UserEntity user = sysUserService.loadUserByUsername(username);

        if (user == null) {
            throw new UsernameNotFoundException("該帳戶不存在,請聯繫管理員添加");
        } else {
            return create(user);
        }
    }

    public UserDetails create(UserEntity user) {
        JwtUser jwtUser = new JwtUser();
        BeanUtils.copyProperties(user, jwtUser);

        Set<String> roleCodeList = new HashSet<>();
//        roleCodeList.addAll(user.getRoleIdList().stream().map(String::valueOf).collect(Collectors.toList()));
// 選取菜單permission做爲權限標識
        roleCodeList.addAll(user.getPermissionList().stream().filter(StringUtils::isNotEmpty).collect(Collectors.toSet()));
        Collection<? extends GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(roleCodeList.toArray(new String[0]));
        jwtUser.setAuthorities(authorities);

        return jwtUser;
    }

}
複製代碼
用戶登錄的流程

上面的部分是用戶帶着token來訪問受權接口,或者不帶token訪問公用接口。那麼token是怎麼生成的呢?咱們須要暴露公開的登錄接口,校驗用戶信息狀態等。成功經過校驗後,把部分用戶信息封裝在token裏面下發給客戶端。 這是一個基於的jjwt的jwtToken工具類:

@Component
@Slf4j
public class JwtTokenUtil {

    private transient Clock clock = DefaultClock.INSTANCE;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

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

    @Autowired
    private RedisManager redisManager;

    private ObjectMapper mapper = new ObjectMapper();

    public JwtUser getJwtUserFromToken(String token) throws Exception {
        String subject = getClaimFromToken(token, Claims::getSubject);
        Map<String, Object> subjectMap = mapper.readValue(subject, Map.class);

        // 在token中存儲了用戶ID 用戶名  用戶狀態
        JwtUser jwtUser = new JwtUser();
        jwtUser.setUserId(Long.valueOf(subjectMap.get("userId").toString()));
        jwtUser.setUsername((String) subjectMap.get("username"));
        jwtUser.setState((Integer) subjectMap.get("state"));

        return jwtUser;
    }

    public Date getIssuedAtDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getIssuedAt);
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    private Boolean isTokenExpired(String token) {
        final Date expirationDate = getExpirationDateFromToken(token);
        return expirationDate.before(clock.now());
    }

    private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
        return (lastPasswordReset != null && created.before(lastPasswordReset));
    }

    private Boolean ignoreTokenExpiration(String token) {
        // here you specify tokens, for that the expiration is ignored
        return false;
    }

    // 登錄校驗成功後調用這個接口生成token下發
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();

        try {
            String subject = mapper.writeValueAsString(userDetails);
            log.info("generateToken subject:{}", subject);
            String token = doGenerateToken(claims, subject);
            redisManager.set(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + token, "1", (int) (expiration / 1000));
            return token;
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("Cannot format json", e);
        }
    }

    private String doGenerateToken(Map<String, Object> claims, String subject) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean canTokenBeRefreshed(String token, Date lastPasswordReset) {
        final Date created = getIssuedAtDateFromToken(token);
        return !isCreatedBeforeLastPasswordReset(created, lastPasswordReset)
                && (!isTokenExpired(token) || ignoreTokenExpiration(token));
    }

    public String refreshToken(String token) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        final Claims claims = getAllClaimsFromToken(token);
        claims.setIssuedAt(createdDate);
        claims.setExpiration(expirationDate);

        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) throws Exception {
        JwtUser user = (JwtUser) userDetails;
        final JwtUser jwtUser = getJwtUserFromToken(token);
        return (
                jwtUser.getUsername().equals(user.getUsername())
                        && !isTokenExpired(token));
    }

    private Date calculateExpirationDate(Date createdDate) {
        //過時時間1天
        return new Date(createdDate.getTime() + 1000 * 60 * 60 * 24);
    }
}
複製代碼
jwt token刷新機制

咱們回顧下token機制相比傳統的session機制帶來的好處,服務無狀態,服務端不用存儲用戶的session,用戶數過多也不會佔用資源,方便服務水平拓展...,token也有一個缺點就是因爲token的有效期是保存在客戶端的,當用戶主動退出,或者服務端要踢出用戶的時候很難作到。refresh token能夠實現這種場景,而且能實現用戶無感知登錄。訪問資源的稱之爲access token,客戶端訪問全部的資源都須要帶上,它的有效期比較短。refresh token是用來刷新access token,它的有效期是比較長的。接下來回顧一下整個會話管理流程:

  • 客戶端使用用戶名和密碼認證
  • 服務端校驗用戶名和密碼,下發access_token(2小時有效)和refresh_token(7天有效)
  • 客戶端帶着access_token訪問須要認證的資源,access_token有效,返回資源。
  • access_token過時,返回和客戶端約定的響應碼,客戶端帶着refresh_token刷新access_token.
  • refresh_token 有效,正常返回,refresh_token過時走從新登錄流程。
  • 客戶端使用新的 access_token 訪問須要認證的接口
    會話管理流程

將生成的refresh_token以及過時時間存儲在服務端的數據庫中,只有在申請新的access_token時纔會驗證。同時咱們也能實如今服務端踢出用戶,只須要禁用|刪除refresh_token,用戶在刷新access_token時就會從新去登錄。(時間精度的控制取決於access_token的有效期)

接口權限控制

當咱們完成了用戶登錄-token下發-請求攔截認證的流程後,當request到達Controller層,SecurityContextHolder已經存儲了用戶的經常使用信息(用戶名,權限標識等等),因此在Controller層能夠直接使用註解來鑑權。

@PreAuthorize("hasAuthority('test_menu_code')")
    @PostMapping("/getUserInfo")
    public ResponseResult getUserInfo() {
        return new ResponseResult(getUser());
    }
複製代碼

至此,完成了整個權限控制。代碼只是列出了關鍵的部分,沒有達到運行的流程,須要有必定基礎的程序員來根據本身的業務定製。只是提供了一個企業級權限控制的實現方案。

相關文章
相關標籤/搜索