001package de.deepamehta.accesscontrol;
002
003import de.deepamehta.accesscontrol.event.PostLoginUserListener;
004import de.deepamehta.accesscontrol.event.PostLogoutUserListener;
005import de.deepamehta.config.ConfigCustomizer;
006import de.deepamehta.config.ConfigDefinition;
007import de.deepamehta.config.ConfigModificationRole;
008import de.deepamehta.config.ConfigService;
009import de.deepamehta.config.ConfigTarget;
010import de.deepamehta.files.FilesService;
011import de.deepamehta.files.event.CheckDiskQuotaListener;
012import de.deepamehta.workspaces.WorkspacesService;
013
014import de.deepamehta.core.Association;
015import de.deepamehta.core.AssociationType;
016import de.deepamehta.core.ChildTopics;
017import de.deepamehta.core.DeepaMehtaObject;
018import de.deepamehta.core.RelatedTopic;
019import de.deepamehta.core.Topic;
020import de.deepamehta.core.TopicType;
021import de.deepamehta.core.ViewConfiguration;
022import de.deepamehta.core.model.AssociationModel;
023import de.deepamehta.core.model.SimpleValue;
024import de.deepamehta.core.model.TopicModel;
025import de.deepamehta.core.osgi.PluginActivator;
026import de.deepamehta.core.service.DeepaMehtaEvent;
027import de.deepamehta.core.service.EventListener;
028import de.deepamehta.core.service.Inject;
029import de.deepamehta.core.service.Transactional;
030import de.deepamehta.core.service.accesscontrol.AccessControl;
031import de.deepamehta.core.service.accesscontrol.AccessControlException;
032import de.deepamehta.core.service.accesscontrol.Credentials;
033import de.deepamehta.core.service.accesscontrol.Operation;
034import de.deepamehta.core.service.accesscontrol.Permissions;
035import de.deepamehta.core.service.accesscontrol.SharingMode;
036import de.deepamehta.core.service.event.PostCreateAssociationListener;
037import de.deepamehta.core.service.event.PostCreateTopicListener;
038import de.deepamehta.core.service.event.PostUpdateAssociationListener;
039import de.deepamehta.core.service.event.PostUpdateTopicListener;
040import de.deepamehta.core.service.event.PreCreateTopicListener;
041import de.deepamehta.core.service.event.PreGetAssociationListener;
042import de.deepamehta.core.service.event.PreGetTopicListener;
043import de.deepamehta.core.service.event.PreUpdateTopicListener;
044import de.deepamehta.core.service.event.ResourceRequestFilterListener;
045import de.deepamehta.core.service.event.ServiceRequestFilterListener;
046import de.deepamehta.core.util.JavaUtils;
047
048// ### TODO: hide Jersey internals. Move to JAX-RS 2.0.
049import com.sun.jersey.spi.container.ContainerRequest;
050
051import javax.servlet.http.HttpServletRequest;
052import javax.servlet.http.HttpSession;
053
054import javax.ws.rs.GET;
055import javax.ws.rs.PUT;
056import javax.ws.rs.POST;
057import javax.ws.rs.DELETE;
058import javax.ws.rs.Consumes;
059import javax.ws.rs.Path;
060import javax.ws.rs.PathParam;
061import javax.ws.rs.Produces;
062import javax.ws.rs.WebApplicationException;
063import javax.ws.rs.core.Context;
064import javax.ws.rs.core.Response;
065import javax.ws.rs.core.Response.Status;
066
067import java.util.Collection;
068import java.util.Enumeration;
069import java.util.concurrent.Callable;
070import java.util.logging.Logger;
071
072
073
074@Path("/accesscontrol")
075@Consumes("application/json")
076@Produces("application/json")
077public class AccessControlPlugin extends PluginActivator implements AccessControlService, ConfigCustomizer,
078                                                                                          PreCreateTopicListener,
079                                                                                          PreUpdateTopicListener,
080                                                                                          PreGetTopicListener,
081                                                                                          PreGetAssociationListener,
082                                                                                          PostCreateTopicListener,
083                                                                                          PostCreateAssociationListener,
084                                                                                          PostUpdateTopicListener,
085                                                                                          PostUpdateAssociationListener,
086                                                                                          ServiceRequestFilterListener,
087                                                                                          ResourceRequestFilterListener,
088                                                                                          CheckDiskQuotaListener {
089
090    // ------------------------------------------------------------------------------------------------------- Constants
091
092    // Security settings
093    private static final String ANONYMOUS_READ_ALLOWED = System.getProperty("dm4.security.anonymous_read_allowed",
094        "ALL");
095    private static final String ANONYMOUS_WRITE_ALLOWED = System.getProperty("dm4.security.anonymous_write_allowed",
096        "NONE");
097    private static final GlobalRequestFilter requestFilter = new GlobalRequestFilter(ANONYMOUS_READ_ALLOWED,
098        ANONYMOUS_WRITE_ALLOWED);
099    private static final String SUBNET_FILTER = System.getProperty("dm4.security.subnet_filter", "127.0.0.1/32");
100    private static final boolean NEW_ACCOUNTS_ARE_ENABLED = Boolean.parseBoolean(
101        System.getProperty("dm4.security.new_accounts_are_enabled", "true"));
102    // Note: the default values are required in case no config file is in effect. This applies when DM is started
103    // via feature:install from Karaf. The default values must match the values defined in project POM.
104    private static final boolean IS_PUBLIC_INSTALLATION = ANONYMOUS_READ_ALLOWED.equals("ALL");
105
106    private static final String AUTHENTICATION_REALM = "DeepaMehta";
107
108    // Type URIs
109    private static final String LOGIN_ENABLED_TYPE = "dm4.accesscontrol.login_enabled";
110    private static final String MEMBERSHIP_TYPE = "dm4.accesscontrol.membership";
111
112    // Property URIs
113    private static final String PROP_CREATOR  = "dm4.accesscontrol.creator";
114    private static final String PROP_OWNER    = "dm4.accesscontrol.owner";
115    private static final String PROP_MODIFIER = "dm4.accesscontrol.modifier";
116
117    // Events
118    private static DeepaMehtaEvent POST_LOGIN_USER = new DeepaMehtaEvent(PostLoginUserListener.class) {
119        @Override
120        public void dispatch(EventListener listener, Object... params) {
121            ((PostLoginUserListener) listener).postLoginUser(
122                (String) params[0]
123            );
124        }
125    };
126    private static DeepaMehtaEvent POST_LOGOUT_USER = new DeepaMehtaEvent(PostLogoutUserListener.class) {
127        @Override
128        public void dispatch(EventListener listener, Object... params) {
129            ((PostLogoutUserListener) listener).postLogoutUser(
130                (String) params[0]
131            );
132        }
133    };
134
135    // ---------------------------------------------------------------------------------------------- Instance Variables
136
137    @Inject
138    private WorkspacesService wsService;
139
140    @Inject
141    private FilesService filesService;
142
143    @Inject
144    private ConfigService configService;
145
146    @Context
147    private HttpServletRequest request;
148
149    private static Logger logger = Logger.getLogger(AccessControlPlugin.class.getName());
150
151    static {
152        logger.info("Security settings:" +
153            "\n  dm4.security.anonymous_read_allowed = " + requestFilter.dumpReadSetting() +
154            "\n  dm4.security.anonymous_write_allowed = " + requestFilter.dumpWriteSetting() +
155            "\n  dm4.security.subnet_filter = " + SUBNET_FILTER +
156            "\n  dm4.security.new_accounts_are_enabled = " + NEW_ACCOUNTS_ARE_ENABLED);
157    }
158
159    // -------------------------------------------------------------------------------------------------- Public Methods
160
161
162
163    // *******************************************
164    // *** AccessControlService Implementation ***
165    // *******************************************
166
167
168
169    // === User Session ===
170
171    @POST
172    @Path("/login")
173    @Override
174    public void login() {
175        // Note: the actual login is performed by the request filter. See requestFilter().
176    }
177
178    @POST
179    @Path("/logout")
180    @Override
181    public void logout() {
182        _logout(request);
183        //
184        // For a "private" DeepaMehta installation: emulate a HTTP logout by forcing the webbrowser to bring up its
185        // login dialog and to forget the former Authorization information. The user is supposed to press "Cancel".
186        // The login dialog can't be used to login again.
187        if (!IS_PUBLIC_INSTALLATION) {
188            throw401Unauthorized(true);     // showBrowserLoginDialog=true
189        }
190    }
191
192    // ---
193
194    @GET
195    @Path("/user")
196    @Produces("text/plain")
197    @Override
198    public String getUsername() {
199        return dm4.getAccessControl().getUsername(request);
200    }
201
202    @GET
203    @Path("/username")
204    @Override
205    public Topic getUsernameTopic() {
206        return dm4.getAccessControl().getUsernameTopic(request);
207    }
208
209    // ---
210
211    @GET
212    @Path("/user/workspace")
213    @Override
214    public Topic getPrivateWorkspace() {
215        String username = getUsername();
216        if (username == null) {
217            throw new IllegalStateException("No user is logged in");
218        }
219        return dm4.getAccessControl().getPrivateWorkspace(username);
220    }
221
222
223
224    // === User Accounts ===
225
226    @POST
227    @Path("/user_account")
228    @Transactional
229    @Override
230    public Topic createUserAccount(final Credentials cred) {
231        try {
232            final String username = cred.username;
233            logger.info("Creating user account \"" + username + "\"");
234            //
235            // 1) create user account
236            AccessControl ac = dm4.getAccessControl();
237            // We suppress standard workspace assignment here as a User Account topic (and its child topics) require
238            // special assignments. See steps 3) and 4) below.
239            Topic userAccount = ac.runWithoutWorkspaceAssignment(new Callable<Topic>() {
240                @Override
241                public Topic call() {
242                    return dm4.createTopic(mf.newTopicModel("dm4.accesscontrol.user_account", mf.newChildTopicsModel()
243                        .put("dm4.accesscontrol.username", username)
244                        .put("dm4.accesscontrol.password", cred.password)));
245                }
246            });
247            ChildTopics childTopics = userAccount.getChildTopics();
248            Topic usernameTopic = childTopics.getTopic("dm4.accesscontrol.username");
249            Topic passwordTopic = childTopics.getTopic("dm4.accesscontrol.password");
250            //
251            // 2) create private workspace
252            Topic privateWorkspace = wsService.createWorkspace(DEFAULT_PRIVATE_WORKSPACE_NAME, null,
253                SharingMode.PRIVATE);
254            setWorkspaceOwner(privateWorkspace, username);
255            // Note: we don't set a particular creator/modifier here as we don't want suggest that the new user's
256            // private workspace has been created by the new user itself. Instead we set the *current* user as the
257            // creator/modifier (via postCreateTopic() listener). In case of the "admin" user account the creator/
258            // modifier remain undefined as it is actually created by the system itself.
259            //
260            // 3) assign user account and password to private workspace
261            // Note: the current user has no READ access to the private workspace just created.
262            // So we must use the privileged assignToWorkspace calls here (instead of using the Workspaces service).
263            long privateWorkspaceId = privateWorkspace.getId();
264            ac.assignToWorkspace(userAccount, privateWorkspaceId);
265            ac.assignToWorkspace(passwordTopic, privateWorkspaceId);
266            //
267            // 4) assign username to "System" workspace
268            // Note: user <anonymous> has no READ access to the System workspace. So we must use privileged calls here.
269            // This is to support the "DM4 Sign-up" 3rd-party plugin.
270            long systemWorkspaceId = ac.getSystemWorkspaceId();
271            ac.assignToWorkspace(usernameTopic, systemWorkspaceId);
272            //
273            return usernameTopic;
274        } catch (Exception e) {
275            throw new RuntimeException("Creating user account \"" + cred.username + "\" failed", e);
276        }
277    }
278
279    @GET
280    @Path("/username/{username}")
281    @Override
282    public Topic getUsernameTopic(@PathParam("username") String username) {
283        return dm4.getAccessControl().getUsernameTopic(username);
284    }
285
286
287
288    // === Workspaces / Memberships ===
289
290    @GET
291    @Path("/workspace/{workspace_id}/owner")
292    @Produces("text/plain")
293    @Override
294    public String getWorkspaceOwner(@PathParam("workspace_id") long workspaceId) {
295        // ### TODO: delegate to Core's AccessControl.getOwner()?
296        return dm4.hasProperty(workspaceId, PROP_OWNER) ? (String) dm4.getProperty(workspaceId, PROP_OWNER) : null;
297    }
298
299    @Override
300    public void setWorkspaceOwner(Topic workspace, String username) {
301        try {
302            workspace.setProperty(PROP_OWNER, username, true);  // addToIndex=true
303        } catch (Exception e) {
304            throw new RuntimeException("Setting the workspace owner of " + info(workspace) + " failed (username=" +
305                username + ")", e);
306        }
307    }
308
309    // ---
310
311    @POST
312    @Path("/user/{username}/workspace/{workspace_id}")
313    @Transactional
314    @Override
315    public void createMembership(@PathParam("username") String username, @PathParam("workspace_id") long workspaceId) {
316        try {
317            dm4.createAssociation(mf.newAssociationModel(MEMBERSHIP_TYPE,
318                mf.newTopicRoleModel(getUsernameTopicOrThrow(username).getId(), "dm4.core.default"),
319                mf.newTopicRoleModel(workspaceId, "dm4.core.default")
320            ));
321        } catch (Exception e) {
322            throw new RuntimeException("Creating membership for user \"" + username + "\" and workspace " +
323                workspaceId + " failed", e);
324        }
325    }
326
327    @Override
328    public boolean isMember(String username, long workspaceId) {
329        return dm4.getAccessControl().isMember(username, workspaceId);
330    }
331
332
333
334    // === Permissions ===
335
336    @GET
337    @Path("/topic/{id}")
338    @Override
339    public Permissions getTopicPermissions(@PathParam("id") long topicId) {
340        return getPermissions(topicId);
341    }
342
343    @GET
344    @Path("/association/{id}")
345    @Override
346    public Permissions getAssociationPermissions(@PathParam("id") long assocId) {
347        return getPermissions(assocId);
348    }
349
350
351
352    // === Object Info ===
353
354    @GET
355    @Path("/object/{id}/creator")
356    @Produces("text/plain")
357    @Override
358    public String getCreator(@PathParam("id") long objectId) {
359        return dm4.getAccessControl().getCreator(objectId);
360    }
361
362    @GET
363    @Path("/object/{id}/modifier")
364    @Produces("text/plain")
365    @Override
366    public String getModifier(@PathParam("id") long objectId) {
367        return dm4.hasProperty(objectId, PROP_MODIFIER) ? (String) dm4.getProperty(objectId, PROP_MODIFIER) : null;
368    }
369
370
371
372    // === Retrieval ===
373
374    @GET
375    @Path("/creator/{username}/topics")
376    @Override
377    public Collection<Topic> getTopicsByCreator(@PathParam("username") String username) {
378        return dm4.getTopicsByProperty(PROP_CREATOR, username);
379    }
380
381    @GET
382    @Path("/owner/{username}/topics")
383    @Override
384    public Collection<Topic> getTopicsByOwner(@PathParam("username") String username) {
385        return dm4.getTopicsByProperty(PROP_OWNER, username);
386    }
387
388    @GET
389    @Path("/creator/{username}/assocs")
390    @Override
391    public Collection<Association> getAssociationsByCreator(@PathParam("username") String username) {
392        return dm4.getAssociationsByProperty(PROP_CREATOR, username);
393    }
394
395    @GET
396    @Path("/owner/{username}/assocs")
397    @Override
398    public Collection<Association> getAssociationsByOwner(@PathParam("username") String username) {
399        return dm4.getAssociationsByProperty(PROP_OWNER, username);
400    }
401
402
403
404    // ****************************
405    // *** Hook Implementations ***
406    // ****************************
407
408
409
410    @Override
411    public void preInstall() {
412        configService.registerConfigDefinition(new ConfigDefinition(
413            ConfigTarget.TYPE_INSTANCES, "dm4.accesscontrol.username",
414            mf.newTopicModel(LOGIN_ENABLED_TYPE, new SimpleValue(NEW_ACCOUNTS_ARE_ENABLED)),
415            ConfigModificationRole.ADMIN, this
416        ));
417    }
418
419    @Override
420    public void shutdown() {
421        // Note 1: unregistering is crucial e.g. for redeploying the Access Control plugin. The next register call
422        // (at preInstall() time) would fail as the Config service already holds such a registration.
423        // Note 2: we must check if the Config service is still available. If the Config plugin is redeployed the
424        // Access Control plugin is stopped/started as well but at shutdown() time the Config service is already gone.
425        if (configService != null) {
426            configService.unregisterConfigDefinition(LOGIN_ENABLED_TYPE);
427        } else {
428            logger.warning("Config service is already gone");
429        }
430    }
431
432
433
434    // ****************************************
435    // *** ConfigCustomizer Implementations ***
436    // ****************************************
437
438
439
440    @Override
441    public TopicModel getConfigValue(Topic topic) {
442        if (!topic.getTypeUri().equals("dm4.accesscontrol.username")) {
443            throw new RuntimeException("Unexpected configurable topic: " + topic);
444        }
445        // the "admin" account must be enabled regardless of the "dm4.security.new_accounts_are_enabled" setting
446        if (topic.getSimpleValue().toString().equals(ADMIN_USERNAME)) {
447            return mf.newTopicModel(LOGIN_ENABLED_TYPE, new SimpleValue(true));
448        }
449        // don't customize
450        return null;
451    }
452
453
454
455    // ********************************
456    // *** Listener Implementations ***
457    // ********************************
458
459
460
461    @Override
462    public void preGetTopic(long topicId) {
463        checkReadPermission(topicId);
464    }
465
466    @Override
467    public void preGetAssociation(long assocId) {
468        checkReadPermission(assocId);
469        //
470        long[] playerIds = dm4.getPlayerIds(assocId);
471        checkReadPermission(playerIds[0]);
472        checkReadPermission(playerIds[1]);
473    }
474
475    // ---
476
477    @Override
478    public void preCreateTopic(TopicModel model) {
479        if (model.getTypeUri().equals("dm4.accesscontrol.username")) {
480            String username = model.getSimpleValue().toString();
481            Topic usernameTopic = getUsernameTopic(username);
482            if (usernameTopic != null) {
483                throw new RuntimeException("Username \"" + username + "\" exists already");
484            }
485        }
486    }
487
488    @Override
489    public void postCreateTopic(Topic topic) {
490        String typeUri = topic.getTypeUri();
491        if (typeUri.equals("dm4.workspaces.workspace")) {
492            setWorkspaceOwner(topic);
493        } else if (typeUri.equals("dm4.webclient.search")) {
494            // ### TODO: refactoring. The Access Control module must not know about the Webclient.
495            // Let the Webclient do the workspace assignment instead.
496            assignSearchTopic(topic);
497        }
498        //
499        setCreatorAndModifier(topic);
500    }
501
502    @Override
503    public void postCreateAssociation(Association assoc) {
504        setCreatorAndModifier(assoc);
505    }
506
507    // ---
508
509    @Override
510    public void preUpdateTopic(Topic topic, TopicModel newModel) {
511        if (topic.getTypeUri().equals("dm4.accesscontrol.username")) {
512            SimpleValue newUsername = newModel.getSimpleValue();
513            String oldUsername = topic.getSimpleValue().toString();
514            if (newUsername != null && !newUsername.toString().equals(oldUsername)) {
515                throw new RuntimeException("A Username can't be changed (tried \"" + oldUsername + "\" -> \"" +
516                    newUsername + "\")");
517            }
518        }
519    }
520
521    @Override
522    public void postUpdateTopic(Topic topic, TopicModel newModel, TopicModel oldModel) {
523        setModifier(topic);
524    }
525
526    @Override
527    public void postUpdateAssociation(Association assoc, AssociationModel newModel, AssociationModel oldModel) {
528        if (isMembership(assoc.getModel())) {
529            if (isMembership(oldModel)) {
530                // ### TODO?
531            } else {
532                wsService.assignToWorkspace(assoc, assoc.getTopicByType("dm4.workspaces.workspace").getId());
533            }
534        } else if (isMembership(oldModel)) {
535            // ### TODO?
536        }
537        //
538        setModifier(assoc);
539    }
540
541    // ---
542
543    @Override
544    public void serviceRequestFilter(ContainerRequest containerRequest) {
545        // Note: we pass the injected HttpServletRequest
546        requestFilter(request);
547    }
548
549    @Override
550    public void resourceRequestFilter(HttpServletRequest servletRequest) {
551        // Note: for the resource filter no HttpServletRequest is injected
552        requestFilter(servletRequest);
553    }
554
555    // ---
556
557    @Override
558    public void checkDiskQuota(String username, long fileSize, long diskQuota) {
559        if (diskQuota < 0) {
560            logger.info("### Checking disk quota of " + userInfo(username) + " ABORTED -- disk quota is disabled");
561            return;
562        }
563        //
564        long occupiedSpace = getOccupiedSpace(username);
565        boolean quotaOK = occupiedSpace + fileSize <= diskQuota;
566        //
567        logger.info("### File size: " + fileSize + " bytes, " + userInfo(username) + " occupies " + occupiedSpace +
568            " bytes, disk quota: " + diskQuota + " bytes => QUOTA " + (quotaOK ? "OK" : "EXCEEDED"));
569        //
570        if (!quotaOK) {
571            throw new RuntimeException("Disk quota of " + userInfo(username) + " exceeded. Disk quota: " +
572                diskQuota + " bytes. Currently occupied: " + occupiedSpace + " bytes.");
573        }
574    }
575
576
577
578    // ------------------------------------------------------------------------------------------------- Private Methods
579
580    private Topic getUsernameTopicOrThrow(String username) {
581        Topic usernameTopic = getUsernameTopic(username);
582        if (usernameTopic == null) {
583            throw new RuntimeException("User \"" + username + "\" does not exist");
584        }
585        return usernameTopic;
586    }
587
588    private boolean isMembership(AssociationModel assoc) {
589        return assoc.getTypeUri().equals(MEMBERSHIP_TYPE);
590    }
591
592    private void assignSearchTopic(Topic searchTopic) {
593        try {
594            Topic workspace;
595            if (getUsername() != null) {
596                workspace = getPrivateWorkspace();
597            } else {
598                workspace = wsService.getWorkspace(WorkspacesService.DEEPAMEHTA_WORKSPACE_URI);
599            }
600            wsService.assignToWorkspace(searchTopic, workspace.getId());
601        } catch (Exception e) {
602            throw new RuntimeException("Assigning search topic to workspace failed", e);
603        }
604    }
605
606    // --- Disk quota ---
607
608    private long getOccupiedSpace(String username) {
609        long occupiedSpace = 0;
610        for (Topic fileTopic : dm4.getTopicsByType("dm4.files.file")) {
611            long fileTopicId = fileTopic.getId();
612            if (getCreator(fileTopicId).equals(username)) {
613                occupiedSpace += filesService.getFile(fileTopicId).length();
614            }
615        }
616        return occupiedSpace;
617    }
618
619
620
621    // === Request Filter ===
622
623    private void requestFilter(HttpServletRequest request) {
624        logger.fine("##### " + request.getMethod() + " " + request.getRequestURL() +
625            "\n      ##### \"Authorization\"=\"" + request.getHeader("Authorization") + "\"" +
626            "\n      ##### " + info(request.getSession(false)));    // create=false
627        //
628        checkRequestOrigin(request);    // throws WebApplicationException 403 Forbidden
629        checkAuthorization(request);    // throws WebApplicationException 401 Unauthorized
630    }
631
632    // ---
633
634    private void checkRequestOrigin(HttpServletRequest request) {
635        String remoteAddr = request.getRemoteAddr();
636        boolean allowed = JavaUtils.isInRange(remoteAddr, SUBNET_FILTER);
637        //
638        logger.fine("Remote address=\"" + remoteAddr + "\", dm4.security.subnet_filter=\"" + SUBNET_FILTER +
639            "\" => " + (allowed ? "ALLOWED" : "FORBIDDEN"));
640        //
641        if (!allowed) {
642            throw403Forbidden();        // throws WebApplicationException
643        }
644    }
645
646    private void checkAuthorization(HttpServletRequest request) {
647        if (request.getSession(false) != null) {    // create=false
648            return;     // authorized already
649        }
650        //
651        boolean authorized;
652        String authHeader = request.getHeader("Authorization");
653        if (authHeader != null) {
654            // Note: if login fails we are NOT authorized, even if no login is required
655            authorized = tryLogin(new Credentials(authHeader), request);
656        } else {
657            authorized = requestFilter.isAnonymousRequestAllowed(request);
658        }
659        if (!authorized) {
660            // Note: a non-public DM installation (anonymous_read_allowed != "ALL") utilizes the browser's login dialog.
661            // (In contrast a public DM installation utilizes DM's login dialog and must suppress the browser's login
662            // dialog.)
663            throw401Unauthorized(!IS_PUBLIC_INSTALLATION);  // throws WebApplicationException
664        }
665    }
666
667    // ---
668
669    /**
670     * Checks weather the credentials are valid and if the user account is enabled, and if both checks are positive
671     * logs the user in.
672     *
673     * @return  true if the user has logged in.
674     */
675    private boolean tryLogin(Credentials cred, HttpServletRequest request) {
676        String username = cred.username;
677        Topic usernameTopic = checkCredentials(cred);
678        if (usernameTopic != null && getLoginEnabled(usernameTopic)) {
679            logger.info("##### Logging in as \"" + username + "\" => SUCCESSFUL!");
680            _login(username, request);
681            return true;
682        } else {
683            logger.info("##### Logging in as \"" + username + "\" => FAILED!");
684            return false;
685        }
686    }
687
688    private Topic checkCredentials(Credentials cred) {
689        return dm4.getAccessControl().checkCredentials(cred);
690    }
691
692    private boolean getLoginEnabled(Topic usernameTopic) {
693        Topic loginEnabled = dm4.getAccessControl().getConfigTopic(LOGIN_ENABLED_TYPE, usernameTopic.getId());
694        return loginEnabled.getSimpleValue().booleanValue();
695    }
696
697    // ---
698
699    private void _login(String username, HttpServletRequest request) {
700        HttpSession session = request.getSession();
701        session.setAttribute("username", username);
702        logger.info("##### Creating new " + info(session));
703        //
704        dm4.fireEvent(POST_LOGIN_USER, username);
705    }
706
707    private void _logout(HttpServletRequest request) {
708        HttpSession session = request.getSession(false);    // create=false
709        String username = username(session);                // save username before invalidating
710        logger.info("##### Logging out from " + info(session));
711        //
712        session.invalidate();
713        //
714        dm4.fireEvent(POST_LOGOUT_USER, username);
715    }
716
717    // ---
718
719    private String username(HttpSession session) {
720        return dm4.getAccessControl().username(session);
721    }
722
723    // ---
724
725    private void throw401Unauthorized(boolean showBrowserLoginDialog) {
726        // Note: to suppress the browser's login dialog a contrived authentication scheme "xBasic"
727        // is used (see http://loudvchar.blogspot.ca/2010/11/avoiding-browser-popup-for-401.html)
728        String authScheme = showBrowserLoginDialog ? "Basic" : "xBasic";
729        throw new WebApplicationException(Response.status(Status.UNAUTHORIZED)
730            .header("WWW-Authenticate", authScheme + " realm=" + AUTHENTICATION_REALM)
731            .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
732            .entity("You're not authorized. Sorry.")
733            .build());
734    }
735
736    private void throw403Forbidden() {
737        throw new WebApplicationException(Response.status(Status.FORBIDDEN)
738            .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
739            .entity("Access is forbidden. Sorry.")
740            .build());
741    }
742
743
744
745    // === Setup Access Control ===
746
747    /**
748     * Sets the logged in user as the creator/modifier of the given object.
749     * <p>
750     * If no user is logged in, nothing is performed.
751     */
752    private void setCreatorAndModifier(DeepaMehtaObject object) {
753        try {
754            String username = getUsername();
755            // Note: when no user is logged in we do NOT fallback to the default user for the access control setup.
756            // This would not help in gaining data consistency because the topics/associations created so far
757            // (BEFORE the Access Control plugin is activated) would still have no access control setup.
758            // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
759            // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
760            // the other hand we don't have such a mechanism (and don't want one either).
761            if (username == null) {
762                logger.fine("Setting the creator/modifier of " + info(object) + " ABORTED -- no user is logged in");
763                return;
764            }
765            //
766            setCreatorAndModifier(object, username);
767        } catch (Exception e) {
768            throw new RuntimeException("Setting the creator/modifier of " + info(object) + " failed", e);
769        }
770    }
771
772    /**
773     * @param   username    must not be null.
774     */
775    private void setCreatorAndModifier(DeepaMehtaObject object, String username) {
776        setCreator(object, username);
777        setModifier(object, username);
778    }
779
780    // ---
781
782    /**
783     * Sets the creator of a topic or an association.
784     */
785    private void setCreator(DeepaMehtaObject object, String username) {
786        try {
787            object.setProperty(PROP_CREATOR, username, true);   // addToIndex=true
788        } catch (Exception e) {
789            throw new RuntimeException("Setting the creator of " + info(object) + " failed (username=" + username + ")",
790                e);
791        }
792    }
793
794    // ---
795
796    private void setModifier(DeepaMehtaObject object) {
797        String username = getUsername();
798        // Note: when a plugin topic is updated there is no user logged in yet.
799        if (username == null) {
800            return;
801        }
802        //
803        setModifier(object, username);
804    }
805
806    private void setModifier(DeepaMehtaObject object, String username) {
807        object.setProperty(PROP_MODIFIER, username, false);     // addToIndex=false
808    }
809
810    // ---
811
812    private void setWorkspaceOwner(Topic workspace) {
813        String username = getUsername();
814        // Note: username is null if the Access Control plugin is activated already
815        // when a 3rd-party plugin creates a workspace at install-time.
816        if (username == null) {
817            return;
818        }
819        //
820        setWorkspaceOwner(workspace, username);
821    }
822
823
824
825    // === Calculate Permissions ===
826
827    /**
828     * @param   objectId    a topic ID, or an association ID
829     */
830    private void checkReadPermission(long objectId) {
831        if (!inRequestScope()) {
832            logger.fine("### Object " + objectId + " is accessed by \"System\" -- READ permission is granted");
833            return;
834        }
835        //
836        String username = getUsername();
837        if (!hasPermission(username, Operation.READ, objectId)) {
838            throw new AccessControlException(userInfo(username) + " has no READ permission for object " + objectId);
839        }
840    }
841
842    /**
843     * @param   objectId    a topic ID, or an association ID.
844     */
845    private Permissions getPermissions(long objectId) {
846        return new Permissions().add(Operation.WRITE, hasPermission(getUsername(), Operation.WRITE, objectId));
847    }
848
849    /**
850     * Checks if a user is permitted to perform an operation on an object (topic or association).
851     *
852     * @param   username    the logged in user, or <code>null</code> if no user is logged in.
853     * @param   objectId    a topic ID, or an association ID.
854     *
855     * @return  <code>true</code> if permission is granted, <code>false</code> otherwise.
856     */
857    private boolean hasPermission(String username, Operation operation, long objectId) {
858        return dm4.getAccessControl().hasPermission(username, operation, objectId);
859    }
860
861    private boolean inRequestScope() {
862        try {
863            request.getMethod();
864            return true;
865        } catch (IllegalStateException e) {
866            // Note: this happens if a request method is called outside request scope.
867            // This is the case while system startup.
868            return false;
869        } catch (NullPointerException e) {
870            // While system startup request might be null.
871            // Jersey might not have injected the proxy object yet.
872            return false;
873        }
874    }
875
876
877
878    // === Logging ===
879
880    private String info(DeepaMehtaObject object) {
881        if (object instanceof TopicType) {
882            return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
883        } else if (object instanceof AssociationType) {
884            return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
885        } else if (object instanceof Topic) {
886            return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() +
887                "\")";
888        } else if (object instanceof Association) {
889            return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")";
890        } else {
891            throw new RuntimeException("Unexpected object: " + object);
892        }
893    }
894
895    private String userInfo(String username) {
896        return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
897    }
898
899    private String info(HttpSession session) {
900        return "session" + (session != null ? " " + session.getId() +
901            " (username=" + username(session) + ")" : ": null");
902    }
903
904    private String info(HttpServletRequest request) {
905        StringBuilder info = new StringBuilder();
906        info.append("    " + request.getMethod() + " " + request.getRequestURI() + "\n");
907        Enumeration<String> e1 = request.getHeaderNames();
908        while (e1.hasMoreElements()) {
909            String name = e1.nextElement();
910            info.append("\n    " + name + ":");
911            Enumeration<String> e2 = request.getHeaders(name);
912            while (e2.hasMoreElements()) {
913                String header = e2.nextElement();
914                info.append(" " + header);
915            }
916        }
917        return info.toString();
918    }
919}