001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018
019package org.fcrepo.auth.webac;
020
021import static java.nio.charset.StandardCharsets.UTF_8;
022import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
023import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate;
024import static org.fcrepo.auth.common.ServletContainerAuthFilter.FEDORA_ADMIN_ROLE;
025import static org.fcrepo.auth.common.ServletContainerAuthFilter.FEDORA_USER_ROLE;
026import static org.fcrepo.auth.webac.URIConstants.FOAF_AGENT_VALUE;
027import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_APPEND;
028import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_CONTROL;
029import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_READ;
030import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_WRITE;
031import static org.fcrepo.kernel.api.FedoraTypes.FCR_ACL;
032import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_BINARY;
033import static org.slf4j.LoggerFactory.getLogger;
034
035import java.io.IOException;
036import java.net.URI;
037import java.security.Principal;
038
039import javax.inject.Inject;
040import javax.servlet.Filter;
041import javax.servlet.FilterChain;
042import javax.servlet.FilterConfig;
043import javax.servlet.ServletException;
044import javax.servlet.ServletRequest;
045import javax.servlet.ServletResponse;
046import javax.servlet.http.HttpServletRequest;
047import javax.servlet.http.HttpServletResponse;
048import javax.ws.rs.core.MediaType;
049import javax.ws.rs.core.UriBuilder;
050
051import org.apache.commons.io.IOUtils;
052import org.apache.jena.query.QueryParseException;
053import org.apache.jena.rdf.model.ModelFactory;
054import org.apache.jena.rdf.model.Resource;
055import org.apache.jena.sparql.modify.request.UpdateDataDelete;
056import org.apache.jena.sparql.modify.request.UpdateModify;
057import org.apache.jena.update.UpdateFactory;
058import org.apache.jena.update.UpdateRequest;
059import org.apache.shiro.SecurityUtils;
060import org.apache.shiro.subject.PrincipalCollection;
061import org.apache.shiro.subject.SimplePrincipalCollection;
062import org.apache.shiro.subject.Subject;
063import org.fcrepo.http.api.FedoraLdp;
064import org.fcrepo.http.commons.api.rdf.HttpResourceConverter;
065import org.fcrepo.http.commons.session.HttpSession;
066import org.fcrepo.http.commons.session.SessionFactory;
067import org.fcrepo.kernel.api.FedoraSession;
068import org.fcrepo.kernel.api.models.FedoraResource;
069import org.fcrepo.kernel.api.services.NodeService;
070import org.slf4j.Logger;
071
072/**
073 * @author peichman
074 */
075public class WebACFilter implements Filter {
076
077    private static final Logger log = getLogger(WebACFilter.class);
078
079    private static final MediaType sparqlUpdate = MediaType.valueOf(contentTypeSPARQLUpdate);
080
081    private FedoraSession session;
082
083    private static final Principal FOAF_AGENT_PRINCIPAL = new Principal() {
084
085        @Override
086        public String getName() {
087            return FOAF_AGENT_VALUE;
088        }
089
090        @Override
091        public String toString() {
092            return getName();
093        }
094
095    };
096
097    private static final PrincipalCollection FOAF_AGENT_PRINCIPAL_COLLECTION =
098            new SimplePrincipalCollection(FOAF_AGENT_PRINCIPAL, WebACAuthorizingRealm.class.getCanonicalName());
099
100    private static Subject FOAF_AGENT_SUBJECT;
101
102    @Inject
103    private NodeService nodeService;
104
105    @Inject
106    private SessionFactory sessionFactory;
107
108    @Override
109    public void init(final FilterConfig filterConfig) {
110        // this method intentionally left empty
111    }
112
113    @Override
114    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
115            throws IOException, ServletException {
116        final Subject currentUser = SecurityUtils.getSubject();
117        HttpServletRequest httpRequest = (HttpServletRequest) request;
118        if (isSparqlUpdate(httpRequest)) {
119            httpRequest = new CachedSparqlRequest(httpRequest);
120        }
121
122        if (currentUser.isAuthenticated()) {
123            log.debug("User is authenticated");
124            if (currentUser.hasRole(FEDORA_ADMIN_ROLE)) {
125                log.debug("User has fedoraAdmin role");
126            } else if (currentUser.hasRole(FEDORA_USER_ROLE)) {
127                log.debug("User has fedoraUser role");
128                // non-admins are subject to permission checks
129                if (!isAuthorized(currentUser, httpRequest)) {
130                    // if the user is not authorized, set response to forbidden
131                    ((HttpServletResponse) response).sendError(SC_FORBIDDEN);
132                    return;
133                }
134            } else {
135                log.debug("User has no recognized servlet container role");
136                // missing a container role, return forbidden
137                ((HttpServletResponse) response).sendError(SC_FORBIDDEN);
138                return;
139            }
140        } else {
141            log.debug("User is NOT authenticated");
142            // anonymous users are subject to permission checks
143            if (!isAuthorized(getFoafAgentSubject(), httpRequest)) {
144                // if anonymous user is not authorized, set response to forbidden
145                ((HttpServletResponse) response).sendError(SC_FORBIDDEN);
146                return;
147            }
148        }
149
150        // proceed to the next filter
151        chain.doFilter(httpRequest, response);
152    }
153
154    private Subject getFoafAgentSubject() {
155        if (FOAF_AGENT_SUBJECT == null) {
156            FOAF_AGENT_SUBJECT = new Subject.Builder().principals(FOAF_AGENT_PRINCIPAL_COLLECTION).buildSubject();
157        }
158        return FOAF_AGENT_SUBJECT;
159    }
160
161    @Override
162    public void destroy() {
163        // this method intentionally left empty
164    }
165
166    private FedoraSession session() {
167        if (session == null) {
168            session = sessionFactory.getInternalSession();
169        }
170        return session;
171    }
172
173    private String getBaseURL(final HttpServletRequest servletRequest) {
174        final String url = servletRequest.getRequestURL().toString();
175        // the base URL will be the request URL if there is no path info
176        String baseURL = url;
177
178        // strip out the path info, if it exists
179        final String pathInfo = servletRequest.getPathInfo();
180        if (pathInfo != null) {
181            final int loc = url.lastIndexOf(pathInfo);
182            baseURL = url.substring(0, loc);
183        }
184
185        log.debug("Base URL determined from servlet request is {}", baseURL);
186        return baseURL;
187    }
188
189    private FedoraResource resource(final HttpServletRequest servletRequest) {
190        return nodeService.find(session(), getRepoPath(servletRequest));
191    }
192
193    private boolean resourceExists(final HttpServletRequest servletRequest) {
194        return nodeService.exists(session(), getRepoPath(servletRequest));
195    }
196
197    private String getRepoPath(final HttpServletRequest servletRequest) {
198        final String httpURI = servletRequest.getRequestURL().toString();
199        final HttpSession httpSession = new HttpSession(session());
200
201        final UriBuilder uriBuilder = UriBuilder.fromUri(getBaseURL(servletRequest)).path(FedoraLdp.class);
202        final HttpResourceConverter conv = new HttpResourceConverter(httpSession, uriBuilder);
203        final Resource resource = ModelFactory.createDefaultModel().createResource(httpURI);
204
205        final String repoPath = conv.asString(resource);
206        log.debug("Converted request URI {} to repo path {}", httpURI, repoPath);
207        return repoPath;
208    }
209
210    private boolean isAuthorized(final Subject currentUser, final HttpServletRequest httpRequest) throws IOException {
211        final String requestURL = httpRequest.getRequestURL().toString();
212        final boolean isAcl = requestURL.endsWith(FCR_ACL);
213        final URI requestURI = URI.create(requestURL);
214        log.debug("Request URI is {}", requestURI);
215
216        // WebAC permissions
217        final WebACPermission toRead = new WebACPermission(WEBAC_MODE_READ, requestURI);
218        final WebACPermission toWrite = new WebACPermission(WEBAC_MODE_WRITE, requestURI);
219        final WebACPermission toAppend = new WebACPermission(WEBAC_MODE_APPEND, requestURI);
220        final WebACPermission toControl = new WebACPermission(WEBAC_MODE_CONTROL, requestURI);
221
222        switch (httpRequest.getMethod()) {
223        case "OPTIONS":
224        case "HEAD":
225        case "GET":
226            if (isAcl) {
227                if (currentUser.isPermitted(toControl)) {
228                    log.debug("GET allowed by {} permission", toControl);
229                    return true;
230                } else {
231                    log.debug("GET prohibited without {} permission", toControl);
232                    return false;
233                }
234            } else {
235                return currentUser.isPermitted(toRead);
236            }
237        case "PUT":
238            if (isAcl) {
239                if (currentUser.isPermitted(toControl)) {
240                    log.debug("PUT allowed by {} permission", toControl);
241                    return true;
242                } else {
243                    log.debug("PUT prohibited without {} permission", toControl);
244                    return false;
245                }
246            } else if (currentUser.isPermitted(toWrite)) {
247                log.debug("PUT allowed by {} permission", toWrite);
248                return true;
249            } else {
250                if (resourceExists(httpRequest)) {
251                    // can't PUT to an existing resource without acl:Write permission
252                    log.debug("PUT prohibited to existing resource without {} permission", toWrite);
253                    return false;
254                } else {
255                    // find nearest parent resource and verify that user has acl:Append on it
256                    // this works because when the authorizations are inherited, it is the target request URI that is
257                    // added as the resource, not the accessTo or other URI in the original authorization
258                    log.debug("Resource doesn't exist; checking parent resources for acl:Append permission");
259                    if (currentUser.isPermitted(toAppend)) {
260                        log.debug("PUT allowed for new resource by inherited {} permission", toAppend);
261                        return true;
262                    } else {
263                        log.debug("PUT prohibited for new resource without inherited {} permission", toAppend);
264                        return false;
265                    }
266                }
267            }
268        case "POST":
269            if (currentUser.isPermitted(toWrite)) {
270                log.debug("POST allowed by {} permission", toWrite);
271                return true;
272            }
273            if (resourceExists(httpRequest)) {
274                if (resource(httpRequest).hasType(FEDORA_BINARY)) {
275                    // LDP-NR
276                    // user without the acl:Write permission cannot POST to binaries
277                    log.debug("POST prohibited to binary resource without {} permission", toWrite);
278                    return false;
279                } else {
280                    // LDP-RS
281                    // user with the acl:Append permission may POST to containers
282                    if (currentUser.isPermitted(toAppend)) {
283                        log.debug("POST allowed to container by {} permission", toAppend);
284                        return true;
285                    } else {
286                        log.debug("POST prohibited to container without {} permission", toAppend);
287                        return false;
288                    }
289                }
290            } else {
291                // prohibit POST to non-existent resources without the acl:Write permission
292                log.debug("POST prohibited to non-existent resource without {} permission", toWrite);
293                return false;
294            }
295        case "DELETE":
296            if (isAcl) {
297                if (currentUser.isPermitted(toControl)) {
298                    log.debug("DELETE allowed by {} permission", toControl);
299                    return true;
300                } else {
301                    log.debug("DELETE prohibited without {} permission", toControl);
302                    return false;
303                }
304            } else {
305                return currentUser.isPermitted(toWrite);
306            }
307        case "PATCH":
308
309            if (isAcl) {
310                if (currentUser.isPermitted(toControl)) {
311                    log.debug("PATCH allowed by {} permission", toControl);
312                    return true;
313                } else {
314                    log.debug("PATCH prohibited without {} permission", toControl);
315                    return false;
316                }
317            } else if (currentUser.isPermitted(toWrite)) {
318                return true;
319            } else {
320                if (currentUser.isPermitted(toAppend)) {
321                    return isPatchContentPermitted(httpRequest);
322                }
323            }
324            return false;
325        default:
326            return false;
327        }
328    }
329
330    private boolean isPatchContentPermitted(final HttpServletRequest httpRequest) throws IOException {
331        if (!isSparqlUpdate(httpRequest)) {
332            log.debug("Cannot verify authorization on NON-SPARQL Patch request.");
333            return false;
334        }
335        if (httpRequest.getInputStream() != null) {
336            boolean noDeletes = false;
337            try {
338                noDeletes = !hasDeleteClause(IOUtils.toString(httpRequest.getInputStream(), UTF_8));
339            } catch (final QueryParseException ex) {
340                log.error("Cannot verify authorization! Exception while inspecting SPARQL query!", ex);
341            }
342            return noDeletes;
343        } else {
344            log.debug("Authorizing SPARQL request with no content.");
345            return true;
346        }
347    }
348
349    private boolean hasDeleteClause(final String sparqlString) {
350        final UpdateRequest sparqlUpdate = UpdateFactory.create(sparqlString);
351        return sparqlUpdate.getOperations().stream()
352                .filter(update -> update instanceof UpdateDataDelete)
353                .map(update -> (UpdateDataDelete) update)
354                .anyMatch(update -> update.getQuads().size() > 0) ||
355                sparqlUpdate.getOperations().stream().filter(update -> (update instanceof UpdateModify))
356                .peek(update -> log.debug("Inspecting update statement for DELETE clause: {}", update.toString()))
357                .map(update -> (UpdateModify)update)
358                .filter(UpdateModify::hasDeleteClause)
359                .anyMatch(update -> update.getDeleteQuads().size() > 0);
360    }
361
362    private boolean isSparqlUpdate(final HttpServletRequest request) {
363        try {
364            return request.getMethod().equals("PATCH") &&
365                    sparqlUpdate.isCompatible(MediaType.valueOf(request
366                            .getContentType()));
367        } catch (final IllegalArgumentException e) {
368            return false;
369        }
370    }
371}