使用Spring Security為菜單增加權(quán)限

上篇我們通過掃描注解自動(dòng)生成了菜單海蔽,本篇我們通過Spring Security來加上權(quán)限驗(yàn)證稍走。

接上篇 http://www.reibang.com/p/ef5854cce4eb


Spring Security配置

Spring Security 是Spring提供的安全控制組件睛廊,它本身提供了很多功能躁锁,我們目前用不到崔挖,Spring Security支持通過配置來啟用串塑、禁用這些功能。本文打算實(shí)現(xiàn)基于Token的認(rèn)證娃承、授權(quán)模式奏夫,先對Spring Security進(jìn)行配置。
Spring Security的詳細(xì)使用可以在網(wǎng)上搜索相關(guān)資料历筝。

@Configuration
@EnableWebSecurity
public class MVCWebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

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

        http
            .exceptionHandling()
                //未登錄時(shí)的handler
                .authenticationEntryPoint(new UnauthenticatedEntryPoint())
                //無權(quán)限時(shí)的handler
                .accessDeniedHandler(new UnauthorizedAccessDeniedHandler())
            .and()
                //如果是微服務(wù) 需要禁用session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                //不關(guān)心跨域
                .csrf().disable()
                //我們自己實(shí)現(xiàn)匿名
                .anonymous().disable()
                //我們自己實(shí)現(xiàn)登錄
                .formLogin().disable()
                //我們自己實(shí)現(xiàn)注銷
                .logout().disable()
                //業(yè)務(wù)邏輯
                .addFilterAt(new TokenAuthenticationFilter(), RememberMeAuthenticationFilter.class)
        ;
    }

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

核心部分

登錄和鑒權(quán)

我們先實(shí)現(xiàn)一個(gè) Token Principal類酗昼,這個(gè)類最終也是 JSP的 userPrincipal

public class AuthorityAuthenticationToken extends AbstractAuthenticationToken {
    private String principal;

    public AuthorityAuthenticationToken(String principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        setAuthenticated(true);
    }

    public AuthorityAuthenticationToken(String principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null; //不支持密碼
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

然后聲明兩個(gè)接口梳猪,分別用于認(rèn)證和鑒權(quán)麻削。

public interface ITokenProvider extends Ordered {
    String getToken(HttpServletRequest request);
}
public interface ITokenConverter extends Ordered {
    AuthorityAuthenticationToken decodeToken(String token);
}

這兩個(gè)接口繼承了org.springframework.core.Ordered接口,然后我們實(shí)現(xiàn)一個(gè)過濾器來進(jìn)行登錄。

public class TokenAuthenticationFilter implements Filter {
    private ITokenProvider[] providers;
    private ITokenConverter[] converters;

    private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    private final AnonymousAuthenticationToken anonymousUser = new AnonymousAuthenticationToken(
            UUID.randomUUID().toString(), "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")
    );

    private String getToken(HttpServletRequest request) {
        if (providers == null) {  //冪等的 所以沒加鎖
            providers = SpringApplicationContextHolder.getInstance()
                .getBeansOfType(ITokenProvider.class)
                .values().stream()
                .sorted(Comparator.comparingInt(ITokenProvider::getOrder)) //有序
                .toArray(ITokenProvider[]::new);
        }

        for (ITokenProvider provider : providers) {
            String token = provider.getToken(request);
            if (StringUtils.hasText(token)) //取第一個(gè)成功的
                return token;
        }

        return null;
    }

    private AuthorityAuthenticationToken decodeToken(String token) {
        if (StringUtils.isEmpty(token))
            return null;

        if (converters == null) {
            converters = SpringApplicationContextHolder.getInstance()
                    .getBeansOfType(ITokenConverter.class)
                    .values().stream()
                    .sorted(Comparator.comparingInt(ITokenConverter::getOrder)) //有序
                    .toArray(ITokenConverter[]::new);
        }

        for (ITokenConverter converter : converters) {
            AuthorityAuthenticationToken u = converter.decodeToken(token);
            if (u != null) //取第一個(gè)成功的
                return u;
        }

        return null;
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        try {
            HttpServletRequest request = (HttpServletRequest) req;
            String token = getToken(request);
            AuthorityAuthenticationToken authToken = null;

            if (StringUtils.hasText(token)) {
                authToken = decodeToken(token);
            }

            if (authToken != null) {
                authToken.setDetails(authenticationDetailsSource.buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            } else {
                SecurityContextHolder.getContext().setAuthentication(anonymousUser);
            }
        } catch (Exception e) {
            SecurityContextHolder.getContext().setAuthentication(anonymousUser);
        }

        chain.doFilter(req, res);
    }

    @Override
    public void destroy() {

    }
}

這里允許 TokenProviderTokenConverter同時(shí)存在多份碟婆,以便在同一個(gè)服務(wù)中支持多種授權(quán)方式电抚。

權(quán)限校驗(yàn)

Spring Security的權(quán)限校驗(yàn)是投票機(jī)制的,默認(rèn)實(shí)現(xiàn)了一票否決制(只要有一票不允許就不能訪問)竖共、一票允許值(與否決相對的蝙叛,只要有一票允許就可以訪問)、多票優(yōu)勝公给。Spring Security默認(rèn)采用的是一票允許值借帘,我們這里采用一票否決制。
此外Spring Security的權(quán)限校驗(yàn)是基于注解的淌铐,我們希望能基于我們的菜單注解肺然,所以需要在投票之前進(jìn)行注解數(shù)據(jù)轉(zhuǎn)換,在這里我們采用一種簡單粗暴的辦法腿准,用一個(gè)Wrapper把投票器包起來际起,在Wrapper中進(jìn)行數(shù)據(jù)轉(zhuǎn)換。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MVCMethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Override
    protected AccessDecisionManager accessDecisionManager() {
        AbstractAccessDecisionManager decisionManager = (AbstractAccessDecisionManager)super.accessDecisionManager();
        //wrapper和一票否決  雖然其實(shí)只有一票
        return new AccessDecisionManagerWrapper(new UnanimousBased(decisionManager.getDecisionVoters()));
    }
}

Wrapper類

public class AccessDecisionManagerWrapper implements AccessDecisionManager {
    private AbstractAccessDecisionManager wrapped;
    private Logger logger = LoggerFactory.getLogger(AccessDecisionManagerWrapper.class);
    private AuthorizeAttributeLoader loader = new AuthorizeAttributeLoader();


    public AccessDecisionManagerWrapper(AbstractAccessDecisionManager wrapped) {
        this.wrapped = wrapped;
    }

    private boolean isAnonymous(Authentication authentication) {
        return (authentication instanceof AnonymousAuthenticationToken);
    }

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        if (authentication == null || !authentication.isAuthenticated()) {    //啥信息都沒有
            wrapped.decide(authentication, object, configAttributes);
        } else {
            //注解數(shù)據(jù)轉(zhuǎn)換
            configAttributes = loader.loadAuthorizeAttribute(object, configAttributes);

            wrapped.decide(authentication, object, configAttributes);
        }
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return wrapped.supports(attribute);
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return wrapped.supports(clazz);
    }
}

注解轉(zhuǎn)換部分吐葱,我們先聲明一個(gè)IAuthorizeAttributeProvider接口街望,以便擴(kuò)展到其他功能。

public interface IAuthorizeAttributeProvider {
    void loadAttribute(Class<?> aClass, Method method, AuthorizeAttributeContainer container);
}
public class AuthorizeAttributeLoader {
    //理論上運(yùn)行過程中 這個(gè)是不會變化的 緩存起來
    private Map<MethodInvocation, Collection<ConfigAttribute>> attrCache = new ConcurrentHashMap<>();

    private Class<?> getTargetClass(MethodInvocation mi) {
        Object target = mi.getThis();

        if (target != null) {
            return target instanceof Class<?> ? (Class<?>) target
                    : AopProxyUtils.ultimateTargetClass(target);
        }

        return null;
    }

    public Collection<ConfigAttribute> loadAuthorizeAttribute(Object object, Collection<ConfigAttribute> configAttributes) {
        if (object instanceof MethodInvocation) {

            MethodInvocation mi = (MethodInvocation) object;
            Collection<ConfigAttribute> result = attrCache.get(mi);
            if (result == null) {
                Method method = mi.getMethod();

                AuthorizeAttributeContainer container = new AuthorizeAttributeContainer(configAttributes);
                SpringApplicationContextHolder.getInstance()
                        .getBeansOfType(IAuthorizeAttributeProvider.class)
                        .forEach((k, v) -> v.loadAttribute(getTargetClass(mi), method, container));

                result = new ArrayList<>(container.getAttributes());
                attrCache.put(mi, result);
            }

            return result;
        }

        return configAttributes;
    }
}

核心功能部分的操作都是冪等的弟跑,所以沒有加鎖灾前,最后我們再實(shí)現(xiàn)未登錄和權(quán)限校驗(yàn)失敗的監(jiān)聽。

//這個(gè)類在MVCWebSecurityConfig注冊的
public class UnauthenticatedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.setStatus(HttpStatus.OK.value());

        //未登錄孟辑,跳轉(zhuǎn)到/401
        RequestDispatcher dispatcher = request.getRequestDispatcher(
                "/401?redirect=" + response.encodeRedirectURL(request.getRequestURI())
        );

        dispatcher.forward(request, response);
    }
//這個(gè)類在MVCWebSecurityConfig注冊的
public class UnauthorizedAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setStatus(HttpStatus.OK.value());
        //沒權(quán)限哎甲,跳轉(zhuǎn)到403
        RequestDispatcher dispatcher = request.getRequestDispatcher(
                "/403?redirect=" + response.encodeRedirectURL(request.getRequestURI())
        );

        dispatcher.forward(request, response);
    }
}

菜單部分

實(shí)現(xiàn)了核心部分后,我們基于核心部分?jǐn)U展修改菜單部分饲嗽。

注解

首先炭玫,在注解中增加權(quán)限碼。

@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@PreAuthorize("isAuthenticated()")  //這個(gè)注解必須有喝噪,啟用Spring Security的投票機(jī)制
public @interface MenuItem {
    String label();
    String icon();
    String code();  //權(quán)限碼
}

然后實(shí)現(xiàn)菜單注解到Spring Security的注解的轉(zhuǎn)換础嫡。

@Component
public class MenuAuthorizeAttributeProvider implements IAuthorizeAttributeProvider {

    private ExpressionBasedAnnotationAttributeFactory attributeFactory = ExpressionAttrFactoryManager.getFactory();

    @Override
    public void loadAttribute(Class<?> aClass, Method method, AuthorizeAttributeContainer container) {
        MenuItem menuItem = method.getDeclaredAnnotation(MenuItem.class);
        if (menuItem == null)
            return;

        String code = menuItem.code();
        if ("*".equals(code))
            return;

        //實(shí)現(xiàn)一個(gè)擴(kuò)展,管理員有所有菜單的權(quán)限
        String exp;
        if ("admin".equals(code)) {
            exp = "hasAuthority('admin')";
        } else {
            //有菜單權(quán)限或是管理員
            exp = "hasAuthority('" + code + "') || hasAuthority('admin')";
        }

        container.addAttribute(
                attributeFactory.createPreInvocationAttribute(null, null, exp)
        );
    }
}

菜單服務(wù)

擴(kuò)展菜單服務(wù)酝惧,能過濾當(dāng)前用戶能看到的菜單列表榴鼎,由于菜單服務(wù)沒有權(quán)限的信息,所以我們需要聲明一個(gè)接口晚唇,由權(quán)限服務(wù)來實(shí)現(xiàn)巫财。

public interface IUserAuthorityProvider {
    Set<String> getCurrentUserAuthorities();
}
     //菜單服務(wù)的主要修改部分
    public List<MenuVO> getUserMenu() {
        Set<String> authorities = provider.getCurrentUserAuthorities();
        if (authorities.isEmpty())
            return Collections.emptyList();

        List<MenuVO> menu = getAllMenu();
        if (authorities.contains("admin"))
            return menu;  //管理員能看到所有菜單

        return menu.stream()
                .filter(m -> authorities.contains(m.getCode()))
                .collect(Collectors.toList());
    }

測試

核心部分和菜單部分分別暴漏了3個(gè)接口,我們依次實(shí)現(xiàn)這3個(gè)接口哩陕。

登錄驗(yàn)證

@Component
public class UrlParameterTokenProvider implements ITokenProvider {
    @Override
    public String getToken(HttpServletRequest request) {
        //這里應(yīng)該是從cookie或http頭獲取用戶是否登錄平项,token是否有效
       //單純測試赫舒,我們從URL獲取
        return request.getParameter("token");
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

鑒權(quán)

@Component
public class TestTokenDecoder implements ITokenConverter {
    @Override
    public AuthorityAuthenticationToken decodeToken(String token) {
        if (StringUtils.isEmpty(token))
            return null;

        //這里是加載用戶有哪些權(quán)限
        List<GrantedAuthority> authorities = new ArrayList<>(2);

        switch (token) {
            case "1":
                //菜單1的權(quán)限
                authorities.add(new SimpleGrantedAuthority("test1"));
                break;

            case "2":
                //菜單2的權(quán)限
                authorities.add(new SimpleGrantedAuthority("test2"));
                break;

            case "admin":
                authorities.add(new SimpleGrantedAuthority("admin"));
                break;
        }

        return new AuthorityAuthenticationToken(token, authorities);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

加載當(dāng)前用戶的權(quán)限

@Component
public class TestUserAuthoritiesProvider implements IUserAuthorityProvider {
    @Override
    public Set<String> getCurrentUserAuthorities() {
        //正常應(yīng)該有業(yè)務(wù)提供這個(gè)數(shù)據(jù),暫時(shí)從Principal獲取
        HttpServletRequest request = ServletHolder.getCurrentRequest();
        Principal principal = request.getUserPrincipal();

        if (principal == null)  //未登錄
            return Collections.emptySet();

        if (!(principal instanceof AbstractAuthenticationToken)) {
            return Collections.emptySet();
        }

        Collection<GrantedAuthority> authorities = ((AbstractAuthenticationToken) principal).getAuthorities();
        return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
    }
}

錯(cuò)誤頁面

public class ErrorController {
    @RequestMapping("/401")
    @ResponseBody
    public String on401(String redirect) {
        //應(yīng)該顯示登錄界面
        return "請登錄后在訪問";
    }

    @RequestMapping("/403")
    @ResponseBody
    public String on403(String redirect) {
        return "您沒有權(quán)限訪問此功能";
    }
}

未登錄

無權(quán)限

普通有權(quán)限用戶

管理員

完整代碼見 https://github.com/giafei/spring-security-token

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末闽瓢,一起剝皮案震驚了整個(gè)濱河市接癌,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌扣讼,老刑警劉巖缺猛,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異椭符,居然都是意外死亡荔燎,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進(jìn)店門销钝,熙熙樓的掌柜王于貴愁眉苦臉地迎上來有咨,“玉大人,你說我怎么就攤上這事蒸健∽恚” “怎么了?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵似忧,是天一觀的道長征讲。 經(jīng)常有香客問我,道長橡娄,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任癣籽,我火速辦了婚禮挽唉,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘筷狼。我一直安慰自己瓶籽,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布埂材。 她就那樣靜靜地躺著塑顺,像睡著了一般。 火紅的嫁衣襯著肌膚如雪俏险。 梳的紋絲不亂的頭發(fā)上严拒,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天,我揣著相機(jī)與錄音竖独,去河邊找鬼裤唠。 笑死,一個(gè)胖子當(dāng)著我的面吹牛莹痢,可吹牛的內(nèi)容都是我干的种蘸。 我是一名探鬼主播墓赴,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼航瞭!你這毒婦竟也來了诫硕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤刊侯,失蹤者是張志新(化名)和其女友劉穎章办,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體滔吠,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡纲菌,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了疮绷。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片翰舌。...
    茶點(diǎn)故事閱讀 38,137評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖冬骚,靈堂內(nèi)的尸體忽然破棺而出椅贱,到底是詐尸還是另有隱情,我是刑警寧澤只冻,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布庇麦,位于F島的核電站,受9級特大地震影響喜德,放射性物質(zhì)發(fā)生泄漏山橄。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一舍悯、第九天 我趴在偏房一處隱蔽的房頂上張望航棱。 院中可真熱鬧,春花似錦萌衬、人聲如沸饮醇。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽朴艰。三九已至,卻和暖如春混移,著一層夾襖步出監(jiān)牢的瞬間祠墅,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工沫屡, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留饵隙,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓沮脖,卻偏偏與公主長得像金矛,于是被迫代替她去往敵國和親芯急。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評論 2 345