Refining Forced Logouts in Programmatic MacOS Parental Controls

It did not take long for the more math- and computer-oriented of our twin sons to figure out the loophole I mentioned at the end of my original post, “Restoring Forced Logouts Removed from MacOS Parental Controls“:

Crafty minds can probably already spot one loophole with how this works. The kids will only get logged out from their current session if the current session has been an hour or more. There is nothing checking their usage for the day. So, if they want to get the most time, they could log in for, say, 50 minutes, and then log in a few minutes later for another 50 minutes or so, and so forth. The fix will involve a more complex refinement. I think I will need to create a small database of total minutes logged in per day and user (for the monitored kids, not the adults), and add 5 minutes for every time they show up as being logged in by the every-5-minute-run of LogTimerApp. Then, if the total time for the day hits the hour limit, then do the logout.

So, I went ahead and put together the more complex refinement. First, I made a new database, creatively named “logintimes”, on my home MariaDB database server. It now contains a single table, with the similarly creative name “logins”, structured as follows.

CREATE TABLE `logins` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(15) NOT NULL,
  `login` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
  `logout` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
)

[Hint: on MySQL and MariaDB, you can recreate this statement by using this command.]

SHOW CREATE TABLE <table-name>

Having created the database to be used, I added the following dependency to the pom.xml file needed to work with it. Note that this is MariaDB-specific; similar dependencies can be found for different DataBase Management Systems (DBMS).

        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <version>2.7.3</version>
        </dependency>

Having created a database table to keep track of logins, I needed to create a corresponding model Object to map to it. I created a new “model” package and added the following code to it:

package biz.noip.johnwatne.logtimer.model;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

/**
 * A representation of the login and, if applicable, logout time for a computer
 * user.
 *
 */
@Entity
@Table(name = "logins")
public class Logins implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private LocalDateTime login;
    private LocalDateTime logout;

    public Logins(final String username, final LocalDateTime login) {
        this.username = username;
        this.login = login;
    }

    /**
     * Default constructor - not used.
     */
    protected Logins() {

    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public LocalDateTime getLogin() {
        return login;
    }

    public void setLogin(LocalDateTime login) {
        this.login = login;
    }

    public LocalDateTime getLogout() {
        return logout;
    }

    public void setLogout(LocalDateTime logout) {
        this.logout = logout;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, login, username);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        Logins other = (Logins) obj;
        return Objects.equals(id, other.id)
                && Objects.equals(login, other.login)
                && Objects.equals(username, other.username);
    }

    @Override
    public String toString() {
        return "Logins [getId()=" + getId() + ", getUsername()=" + getUsername()
                + ", getLogin()=" + getLogin() + ", getLogout()=" + getLogout()
                + "]";
    }

}

With the model object created, I then needed to create an object containing the queries to get information I needed. I have worked extensively with Hibernate-specific and JPA / JPQL style queries in the past, but thought I would take this opportunity to familiarize myself a tiny bit with Spring Data. I found that it is possible to create just an interface that extends Spring Data’s CrudRepository<T, ID extends Serializable> interface, with the methods annotated with the appropriate JPA query, using far fewer lines of code than either the Hibernate or JPA DAOs I have written in the past. I created a new “repository” package and added my “LoginsRepository” class to it, consisting of the following for the four queries it needed to implement.

package biz.noip.johnwatne.logtimer.repository;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;

import biz.noip.johnwatne.logtimer.model.Logins;

public interface LoginsRepository extends CrudRepository<Logins, Long> {
    List<Logins> findByUsername(final String username);

    @Query("select l from Logins l where l.username = :username and l.login = :login")
    List<Logins> findByUsernameAndLogin(@Param("username") String username,
            @Param("login") LocalDateTime login);

    @Query("select l from Logins l where l.username = :username and l.login between :start and :end")
    List<Logins> findByUsernameAndLoginDay(@Param("username") String username,
            @Param("start") LocalDateTime start,
            @Param("end") LocalDateTime end);

    @Query("select l from Logins l where l.login < CURRENT_DATE")
    List<Logins> findLoggedInBeforeToday();

}

The one method not annotated with an “@Query(…)” annotation, findByUsername(String username), did not require it because, since Logins only contains one String attribute named login, Spring Data is smart enough to recognize the single attribute of the given type, matching the parameter name specified, and generate the query automatically. I found that very impressive.

Next, I replaced the last line of code within the LogtimerApp.checkLoginTimes(userLogins) method with the following, so that a logout would be attempted only if at least one process is found for the user.

            // Get number of lines in list returned from "ps -u username" call.
            final ProcessBuilder processBuilder =
                    new ProcessBuilder("/bin/sh", "-c", "ps -u " + user);
            final Process process = processBuilder.start();
            int lines = 0;

            try (final BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                while ((reader.readLine()) != null) {
                    lines++; // Another line read from file.
                }
            }

            if (lines > 1) {
                // More than header line, so actually running process.
                logoutIfKidLoggedInTooLong(user, minutes);
            } else {
                LOGGER.debug("No processes for user " + user
                        + "; already logged out.");
            }

Next, I worked on persisting the user history of logins, and counting the minutes of usage for the current day. I moved most of the code from LogTimerApp to a new service, LogtimerRunner, which implements Spring’s CommandLineRunner interface, which means that it is

a bean [that] should run when it is contained within a SpringApplication

Spring Boot Javadocs

The code is as follows.

package biz.noip.johnwatne.logtimer.service;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Service;

import biz.noip.johnwatne.logtimer.UserLoginTime;
import biz.noip.johnwatne.logtimer.model.Logins;
import biz.noip.johnwatne.logtimer.repository.LoginsRepository;

/**
 * Service that does the work for the LogtimerApp.
 *
 * @author John Watne
 *
 */
@Service
public class LogtimerRunner implements CommandLineRunner {
    private static final String [KID1] = "[kid1]";
    private static final String [KID2] = "[kid2]";
    private static final Logger LOGGER =
            LogManager.getLogger(LogtimerRunner.class);
    private static final DateTimeFormatter DATE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy MMM d HH:mm");

    @Autowired
    private LoginsRepository loginsRepository;

    @Override
    public void run(String... args) throws Exception {
        try {
            final Map<String, List<UserLoginTime>> userLogins =
                    this.getUserLogins();

            if (userLogins != null) {
                this.checkLoginTimes(userLogins);
            }
        } catch (final Exception e) {
            LOGGER.error("Error checking logout times or logging out user", e);
        }
    }

    /**
     * Checks the time logged in for the most recent login for each user.
     *
     * @param userLogins
     *            Lists of login times for each user.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if a thread is interrupted.
     */
    private void
            checkLoginTimes(final Map<String, List<UserLoginTime>> userLogins)
                    throws IOException, InterruptedException {
        LOGGER.debug("*** User login lists ****");
        final LocalDateTime now = LocalDateTime.now();
        LOGGER.info("*** now: " + now.format(DATE_TIME_FORMATTER));
        final LocalDateTime startOfDay = LocalDateTime.of(now.getYear(),
                now.getMonthValue(), now.getDayOfMonth(), 0, 0);
        LOGGER.info(
                "*** startOfDay: " + startOfDay.format(DATE_TIME_FORMATTER));
        final LocalDateTime endOfDay = startOfDay.plusDays(1);
        LOGGER.info("*** endOfDay: " + endOfDay.format(DATE_TIME_FORMATTER));
        deleteDataFromBeforeToday();

        for (final Entry<String, List<UserLoginTime>> entry : userLogins
                .entrySet()) {
            final String user = entry.getKey();
            LOGGER.info("*** user: " + user);

            for (Logins login : loginsRepository.findByUsername(user)) {
                LOGGER.info(login);
            }

            LocalDateTime lastLoginTime =
                    checkPersistedLogins(startOfDay, entry);
            // Get number of lines in list returned from system call to get
            // number of "login" processes for user.
            long lines = getCountOfLoginProcessesForUser(user);

            if (lines < 1) {
                setLogoutForUserWithNoProcesses(user, lastLoginTime);
            } else {
                countMinutesOfUseForDayAndLogoutIfChildExceededMax(now,
                        startOfDay, endOfDay, user);
            }
        }

        setLogoutTimesForUsersNotLoggedIn();
    }

    /**
     * Checks current list of users against persisted information, updating
     * persisted information as needed.
     *
     * @param startOfDay
     *            midnight of the current day.
     * @param entry
     *            the List of UserLoginTimes for the specified username.
     * @return the most recent login time for the user.
     */
    private LocalDateTime checkPersistedLogins(final LocalDateTime startOfDay,
            final Entry<String, List<UserLoginTime>> entry) {
        final String user = entry.getKey();
        final List<UserLoginTime> loginsForUser = entry.getValue();
        final UserLoginTime lastLoginForUser = Collections.max(loginsForUser);
        LOGGER.info("*** Maximum login: " + lastLoginForUser);

        // Check for persisted logins for user.
        LocalDateTime lastLoginTime = lastLoginForUser.getLoginTime();

        for (UserLoginTime loginTime : loginsForUser) {
            final LocalDateTime time = loginTime.getLoginTime();
            List<Logins> usernameAndLogin =
                    loginsRepository.findByUsernameAndLogin(user, time);

            if ((time.isAfter(startOfDay)) && ((usernameAndLogin == null)
                    || usernameAndLogin.isEmpty())) {
                // Need to create new entry.
                Logins newLogin = new Logins(user, time);
                newLogin = loginsRepository.save(newLogin);
                usernameAndLogin.add(newLogin);
            }

            for (Logins logins : usernameAndLogin) {
                // Check if the logout is null and, if the login is not the
                // lastLoginForUser, set logout to now.
                if ((logins.getLogout() == null)
                        && (logins.getLogin().isBefore(lastLoginTime))) {
                    LOGGER.info("*** Setting logout to now");
                    logins.setLogout(LocalDateTime.now());
                    loginsRepository.save(logins);
                }

                LOGGER.info(logins);
            }
        }

        return lastLoginTime;
    }

    /**
     * Sets logout time to now for the specified user, who has no processes
     * running.
     *
     * @param user
     *            a user who has no running processes.
     * @param lastLoginTime
     *            the last time the user logged in.
     */
    private void setLogoutForUserWithNoProcesses(final String user,
            LocalDateTime lastLoginTime) {
        // Only header line, so not running any process.
        final LocalDateTime fromTemp = LocalDateTime.from(lastLoginTime);
        LOGGER.debug("No processes for user " + user + "; already logged out.");
        for (Logins logins : loginsRepository.findByUsernameAndLogin(user,
                fromTemp)) {
            if (logins.getLogout() == null) {
                // Set logout to current time.
                logins.setLogout(LocalDateTime.now());
            }
        }
    }

    /**
     * Counts the minutes the specified user has been logged in today and, if
     * the user is one of the tracked children and they have exceeded their max
     * for the day, logs them out.
     *
     * @param now
     *            Current date/time; used to calculate minutes of usage.
     * @param startOfDay
     *            midnight of the current day.
     * @param endOfDay
     *            midnight at the end of the current day.
     * @param user
     *            the user whose minutes of usage are being calculated.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if a thread is interrupted.
     */
    private void countMinutesOfUseForDayAndLogoutIfChildExceededMax(
            final LocalDateTime now, final LocalDateTime startOfDay,
            final LocalDateTime endOfDay, final String user)
            throws IOException, InterruptedException {
        LOGGER.info("*** Counting minutes");
        long minutes = 0;

        for (Logins logins : this.loginsRepository
                .findByUsernameAndLoginDay(user, startOfDay, endOfDay)) {
            LOGGER.info(logins);
            final LocalDateTime logout = logins.getLogout();

            if (logout == null) {
                // Not yet logged out; current session.
                minutes += logins.getLogin().until(now, ChronoUnit.MINUTES);
            } else {
                minutes += logins.getLogin().until(logout, ChronoUnit.MINUTES);
            }

            LOGGER.info("*** updated minutes: " + minutes);
        }

        LOGGER.info("*** Total minutes for user " + user + ": " + minutes);
        logoutIfKidLoggedInTooLong(user, minutes);
    }

    /**
     * Sets the logout time for users no longer logged in, if not already set,
     * to the specified logout time.
     *
     * @throws IOException
     *             if an I/O error occurs.
     */
    private void setLogoutTimesForUsersNotLoggedIn() throws IOException {
        // Do final pass, setting logout times for users no longer logged in.
        for (Logins persistedLogin : loginsRepository.findAll()) {
            if ((persistedLogin.getLogout() == null)
                    && (getCountOfLoginProcessesForUser(
                            persistedLogin.getUsername()) < 2)) {
                // User logged out but not yet indicated as such, so set logout
                // time to now.
                LOGGER.info("*** " + persistedLogin.getUsername()
                        + " logged out; setting logout to now");
                persistedLogin.setLogout(LocalDateTime.now());
                loginsRepository.save(persistedLogin);
                LOGGER.info("*** Record updated: " + persistedLogin);
            }
        }
    }

    /**
     * Deletes database records for logins before today. Assumes kids not
     * allowed to be logged in over midnight hour.
     */
    private void deleteDataFromBeforeToday() {
        LOGGER.info("*** old logins: ");
        List<Logins> oldLogins = loginsRepository.findLoggedInBeforeToday();

        for (Logins oldLogin : oldLogins) {
            LOGGER.info(oldLogin);
        }

        if (!oldLogins.isEmpty()) {
            reEnableLoginsForKidsAccountsDisabledBeforeToday(oldLogins);
            LOGGER.info("*** deleting old logins");
            loginsRepository.deleteAll(oldLogins);
            List<Logins> updatedList =
                    loginsRepository.findLoggedInBeforeToday();
            LOGGER.info("*** All deleted? "
                    + ((updatedList == null) || (updatedList.isEmpty())));
        }
    }

    /**
     * Re-enables login accounts for kids whose accounts may have been disabled
     * by a call to the script forcing a logout before today's date.
     *
     * @param oldLogins
     *            a List of Logins for users logged in before today.
     */
    private void reEnableLoginsForKidsAccountsDisabledBeforeToday(
            final List<Logins> oldLogins) {
        oldLogins.stream().map(Logins::getUsername).distinct()
                .filter(p -> (KID2.equals(p) || KID1.equals(p)))
                .forEach(u -> {
                    try {
                        reEnableLoginForUser(u);
                    } catch (IOException e) {
                        LOGGER.error("IOException thrown", e);
                    } catch (InterruptedException e) {
                        LOGGER.error("InterruptedException thrown", e);
                    }
                });
    }

    /**
     * Re-enables the login account for the specified username.
     *
     * @param username
     *            the username for the account to be re-enabled.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if a thread is interrupted.
     */
    private void reEnableLoginForUser(final String username)
            throws IOException, InterruptedException {
        LOGGER.info("Restoring login for user " + username);
        ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh", "-c",
                "./restore-login.sh " + username);
        processBuilder.directory(new File("/Users/John"));
        Process process = processBuilder.start();

        try (final BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line;

            while ((line = reader.readLine()) != null) {
                LOGGER.info(line);
            }

            LOGGER.debug(process.waitFor());
        }
    }

    /**
     * Returns the number of processes containing the word &quot;login&quot; for
     * the specified user.
     *
     * @param user
     *            the unique username for the user whose process count is
     *            sought.
     * @return the number of processes containing the word &quot;login&quot; for
     *         the specified user.
     * @throws IOException
     *             if an I/O error occurs.
     */
    private long getCountOfLoginProcessesForUser(final String user)
            throws IOException {
        long lines = 0;
        final ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh",
                "-c", "ps aux | grep login | grep -v grep | grep -i ^" + user);
        Process loginCountProcess = processBuilder.start();

        try (final BufferedReader reader = new BufferedReader(
                new InputStreamReader(loginCountProcess.getInputStream()))) {
            lines = reader.lines().peek(e -> LOGGER.info(e)).count();
        }

        LOGGER.info("*** countOfLoginProcessesForUser: " + lines);
        return lines;
    }

    /**
     * Logs out the specified user if
     * <ol>
     * <li>they are a named child within the family, and</li>
     * <li>the number of minutes they have been logged in exceeds the maximum
     * limit.</li>
     * </ol>
     *
     * @param user
     *            the user whose login time is being checked.
     * @param minutes
     *            the number of minutes the user has been logged in.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if an thread is interrupted.
     */
    private void logoutIfKidLoggedInTooLong(final String user,
            final long minutes) throws IOException, InterruptedException {
        switch (user) {
        case KID2:
        case KID1:
            if (minutes > 59) {
                LOGGER.info("Logging out user " + user);
                ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh",
                        "-c", "./logout-user.sh " + user);
                processBuilder.directory(new File("/Users/John"));
                Process process = processBuilder.start();

                try (final BufferedReader reader = new BufferedReader(
                        new InputStreamReader(process.getInputStream()))) {
                    String line;

                    while ((line = reader.readLine()) != null) {
                        LOGGER.info(line);
                    }

                    LOGGER.debug(process.waitFor());
                }
            }
            // Consider adding warning when 5-10 minutes left, based on
            // osascript -e 'tell app "System Events" to display dialog "Hello
            // World"'

            break;
        default:
            LOGGER.debug("Time limits not enforced for user " + user);
        }
    }

    /**
     * Returns a Map of Lists of login times for each user.
     *
     * @return a Map of Lists of login times for each user.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if a thread is interrupted.
     */
    private Map<String, List<UserLoginTime>> getUserLogins()
            throws IOException, InterruptedException {
        final Map<String, List<UserLoginTime>> userLogins = new HashMap<>();
        ProcessBuilder processBuilder = new ProcessBuilder("bash", "-c", "who");
        Process process = processBuilder.start();

        try (final BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line;

            while ((line = reader.readLine()) != null) {
                final UserLoginTime userLoginTime = new UserLoginTime(line);
                LOGGER.debug(userLoginTime);
                final String user = userLoginTime.getUser();
                List<UserLoginTime> list = userLogins.get(user);

                if (list == null) {
                    list = new ArrayList<>();
                    userLogins.put(user, list);
                }

                list.add(userLoginTime);
            }
        }

        LOGGER.debug(process.waitFor());
        return userLogins;
    }

}

With most of the code moved out of it, I converted LogtimerApp to a properly annotated Spring Boot application to make use of the new service. The revised, much simpler application class is as follows.

package biz.noip.johnwatne.logtimer;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Application that checks operating system for logged in users and determines
 * how long they've been logged in for the day. If longer than allowed amount,
 * logs them out. This is designed specifically for a Mac, so no provision is
 * made for Windows commands.
 *
 * @author John Watne
 *
 */
@SpringBootApplication
public class LogtimerApp {
    /**
     * Main method.
     *
     * @param args
     *            command-line arguments; not used.
     */
    public static void main(final String[] args) {
        SpringApplication.run(LogtimerApp.class, args);
    }
}

I added the needed Spring Data database connection information into src/main/resources/application.properties.

spring.datasource.url=jdbc:mariadb://[hostname]:[port]/logintimes
spring.datasource.username=[username]
spring.datasource.password=[password]
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
hibernate.id.new_generator_mappings=false

I added to the pom.xml file the dependencies I needed and removed some no longer used, and made some fixes to use slf4j logging, implemented by log4j 2. The latter change also involved renaming src/main/resources/log4j2.xml to log4j2-spring.xml, to let Spring find it automatically. The revised pom.xml file is as follows.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.2</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>johnwatne</groupId>
    <artifactId>logtimer</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>logtimer</name>
    <description>Kid's computer usage monitor and logout tool</description>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>versions-maven-plugin</artifactId>
                <configuration>
                    <generateBackupPoms>false</generateBackupPoms>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <release>11</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.1.0</version><!--$NO-MVN-MAN-VER$ -->
            </plugin>
        </plugins>
    </build>
</project>

In case you were wondering about the “reEnableLoginForUser(…)” method added to the LogtimerRunner class, that was a slightly later addition to the code. The same son who spotted the first loophole, thinking as I likely would have in his position, spotted the loophole that the check of login times was done every 5 minutes. So, after being kicked off, he would log right back in and play until the next scheduled check of times. So, after researching a bit on how temporarily to hide user logins from the login window, I added the following to the end of the original logout-user.sh script:

# Temporarily remove login from login window.
echo "$PASS" | sudo -S dscl . create /Users/$1 IsHidden 1

The “IsHidden” flag needs to be reset to 0 [false] by the new script, restore-login.sh, called by the previously mentioned reEnableLoginForUser method. It’s code is as follows.

#!/bin/bash

# See https://brettterpstra.com/2021/04/06/scripting-with-sudo-on-mac/ for technique
# of "Scripting with sudo on Mac".

PASS=$(security find-generic-password -l "[keychain password alias]" -a [sudo username] -w|tr -d '\n')
echo "$PASS" | sudo -S dscl . create /Users/$1 IsHidden 0

The completed code seems to be working quite well. The one glitch remaining is that, even after their accounts are restored, our boys’ accounts don’t show up in the quick user switch menu drop-down. I did attempt to add a five minute warning message, but have yet to figure out a way to get it to work when executed by a call from my program.

I hope readers may find some useful things in this experiment of mine, perhaps just ideas for things to research on your own.

Restoring Forced Logouts Removed from MacOS Parental Controls

At some point between the release of MacOS “Mojave” and MacOS “Big Sur”, one of the key features that I liked about the parental controls — the ability to force logouts from the Mac after the time quota for the day was met — was removed. This is my least favorite removal of a previous feature of the Mac operating system since the removal of the photo mosaic screen saver with the change from Lion to Mountain Lion (see this old video for an example of what it used to look like). While I know the focus now is on screen time specific to various apps across all devices, we really only have the one Mac that our boys use and which we want to limit their time on, and we don’t want them to use up their allotted hour on, say, Minecraft, and then spend another hour browsing YouTube videos in a web browser.

Since I could not find any way to force logouts after a time using the current “Screen Time” settings in MacOS Big Sur, I decided I would have to come up with my own programmatic solution. I knew the command line “who” command would show the login time for all users on the machine, so figured I would need a scheduled job to monitor login times and, when it showed either of our boys, Paul or Michael, signed on for over an hour, it was time to force a logout. The tricky part was that, in order to force the logout of other users, I need to do so as a superuser using the “sudo” command — which requires entering my password. How was I to send that password to the “sudo” command without the security risk of exposing the password as plaintext at some point?

I finally came across a solution when my searches eventually led me to a marvelous post, “Scripting with sudo on a Mac“, by fellow Minnesotan Brett Terpstra. Combine that with what I already knew about crontab scripting, bash scripting, and Java, and learning a bit more about the newer time and date classes added in version 8 of Java, and looking at some examples of using Java ProcessBuilders to make operating system process calls by mkyong and Jonathan Cook on Baeldung.com, and I was able to whip up a working setup within 24 hours.

The first part was to write the script to do the logout. After adding the password to the MacOS keychain per the previously mentioned “Scripting with Sudo on a Mac” article, I started with the example script and modified it as needed to create the following “logout-user.sh”, with certain portions replaced by [items in brackets to represent where you want to put your entries].

#!/bin/bash
# See https://brettterpstra.com/2021/04/06/scripting-with-sudo-on-mac/ for technique
# of "Scripting with sudo on Mac".

PASS=$(security find-generic-password -l "[keychain password alias]" -a [sudo username] -w|tr -d '\n')
echo "$PASS" | sudo -S launchctl bootout user/$(id -u $1)

Next, I started work on the Java application, creating it as a simple Maven project, and creating a runnable .jar file using the “maven-shade-plugin”. The pom.xml file looks as follows:

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>johnwatne</groupId>
    <artifactId>logtimer</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>logtimer</name>
    <description>Kid's computer usage monitor and logout tool</description>
    <dependencies>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.14.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.14.1</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>versions-maven-plugin</artifactId>
                <version>2.5</version>
                <configuration>
                    <generateBackupPoms>false</generateBackupPoms>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.0</version>
                <configuration>
                    <release>11</release>
                </configuration>
            </plugin>
            <!-- Maven Shade Plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>2.3</version>
                <executions>
                    <!-- Run shade goal on package phase -->
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <!-- add Main-Class to manifest file -->
                                <transformer
                                    implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>biz.noip.johnwatne.logtimer.LogtimerApp</mainClass>
                                </transformer>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

I created a “UserLoginTime” model object to represent the information in the output of the “who” command. It implements the “Comparable” interface in such a way that the natural sort order of such objects is by their “loginTime” attribute, allowing picking the most recent login time if multiple entries appear for a user, as is often the case. That code is as follows.

package biz.noip.johnwatne.logtimer;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;

/**
 * Class representing time a user has logged in to a specific tty. Takes output
 * parsed from MacOS / BSD &quot;who&quot; command.
 *
 * @author John Watne
 *
 */
public class UserLoginTime implements Comparable<UserLoginTime> {
    private static final DateTimeFormatter DATE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy MMM d HH:mm");
    private String user;
    private String tty;
    private LocalDateTime loginTime;

    /**
     * Constructs a UserLoginTime for the information in the passed line from
     * &quot;who&quot; output.
     *
     * @param line
     *            a line of output from the MacOS / BSD &quot;who&quot; command.
     */
    public UserLoginTime(final String line) {
        final LocalDateTime now = LocalDateTime.now();
        final int year = now.getYear();
        final String[] split = line.split("[\\s]+");
        this.setUser(split[0]);
        this.setTty(split[1]);
        final StringBuilder builder = new StringBuilder();
        builder.append(year);

        for (int i = 2; (i < Math.min(split.length, 5)); i++) {
            builder.append(" ");
            builder.append(split[i]);
        }

        this.setLoginTime(
                LocalDateTime.parse(builder.toString(), DATE_TIME_FORMATTER));
    }

    public String getUser() {
        return user;
    }

    public void setUser(String user) {
        this.user = user;
    }

    public String getTty() {
        return tty;
    }

    public void setTty(String tty) {
        this.tty = tty;
    }

    public LocalDateTime getLoginTime() {
        return loginTime;
    }

    public void setLoginTime(LocalDateTime loginTime) {
        this.loginTime = loginTime;
    }

    @Override
    public String toString() {
        return "UserLoginTime [getUser()=" + getUser() + ", getTty()="
                + getTty() + ", getLoginTime()="
                + getLoginTime().format(DATE_TIME_FORMATTER) + "]";
    }

    @Override
    public int hashCode() {
        return Objects.hash(loginTime, user);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        UserLoginTime other = (UserLoginTime) obj;
        return Objects.equals(loginTime, other.loginTime)
                && Objects.equals(user, other.user);
    }

    @Override
    public int compareTo(UserLoginTime o) {
        return this.getLoginTime().compareTo(o.getLoginTime());
    }

}

The StringBuilder is initialized with the current year. Then, the month and day are read from the input String and appended to it, and the DATE_TIME_FORMATTER is used to obtain the correct value from the resulting built-up String.

Now, we can see how such UserLoginTimes are used by the logic of the program, contained in the LogtimerApp class, which will be presented in sections below. From the top:

package biz.noip.johnwatne.logtimer;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

/**
 * Application that checks operating system for logged in users and determines
 * how long they've been logged in for the day. If longer than allowed amount,
 * logs them out. This is designed specifically for a Mac, so no provision is
 * made for Windows commands.
 *
 * @author John Watne
 *
 */
public class LogtimerApp {
    private static final Logger LOGGER =
            LogManager.getLogger(LogtimerApp.class);

    /**
     * Main method.
     *
     * @param args
     *            command-line arguments; not used.
     */
    public static void main(final String[] args) {
        final LogtimerApp timer = new LogtimerApp();

        try {
            final Map<String, List<UserLoginTime>> userLogins =
                    timer.getUserLogins();

            if (userLogins != null) {
                timer.checkLoginTimes(userLogins);
            }
        } catch (final Exception e) {
            LOGGER.error("Error checking logout times or logging out user", e);
        }
    }

Original early iterations of the coding had all output going to the console. This was changed to have output logged to a log file, whose log4j2 configuration will be shown later. The main method constructs an instance of the class, then initializes a Map of usernames to their last login time. If the List is not null, then the user login times are checked. The Map is obtained by the getUserLogins() method:

    /**
     * Returns a Map of Lists of login times for each user.
     *
     * @return a Map of Lists of login times for each user.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if a thread is interrupted.
     */
    private Map<String, List<UserLoginTime>> getUserLogins()
            throws IOException, InterruptedException {
        final Map<String, List<UserLoginTime>> userLogins = new HashMap<>();
        ProcessBuilder processBuilder = new ProcessBuilder("bash", "-c", "who");
        Process process = processBuilder.start();

        try (final BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line;

            while ((line = reader.readLine()) != null) {
                final UserLoginTime userLoginTime = new UserLoginTime(line);
                LOGGER.debug(userLoginTime);
                final String user = userLoginTime.getUser();
                List<UserLoginTime> list = userLogins.get(user);

                if (list == null) {
                    list = new ArrayList<>();
                    userLogins.put(user, list);
                }

                list.add(userLoginTime);
            }
        }

        LOGGER.debug(process.waitFor());
        return userLogins;
    }

So, for each line in the output of the “who” command, a UserLoginTime object is created and added to the List of login times for the specified user. This map is then returned by the method.

The user login times are checked by the following method:

    /**
     * Checks the time logged in for the most recent login for each user.
     *
     * @param userLogins
     *            Lists of login times for each user.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if a thread is interrupted.
     */
    private void
            checkLoginTimes(final Map<String, List<UserLoginTime>> userLogins)
                    throws IOException, InterruptedException {
        LOGGER.debug("*** User login lists ****");
        final LocalDateTime now = LocalDateTime.now();

        for (final Entry<String, List<UserLoginTime>> entry : userLogins
                .entrySet()) {
            final String user = entry.getKey();
            final List<UserLoginTime> loginsForUser = entry.getValue();
            final UserLoginTime lastLoginForUser =
                    Collections.max(loginsForUser);
            LOGGER.info("*** Maximum login: " + lastLoginForUser);
            final LocalDateTime fromTemp =
                    LocalDateTime.from(lastLoginForUser.getLoginTime());
            final long minutes = fromTemp.until(now, ChronoUnit.MINUTES);
            LOGGER.debug("Elapsed time: " + minutes + " minutes");
            logoutIfKidLoggedInTooLong(user, minutes);
        }
    }

Having obtained the latest login time for each user, that user’s time is checked against the maximum allowed if it is one of the kids and, if it exceeds it, a call is made to the “logout-user.sh” script, passing it the username to logout, by the following method.

    /**
     * Logs out the specified user if
     * <ol>
     * <li>they are a named child within the family, and</li>
     * <li>the number of minutes they have been logged in exceeds the maximum
     * limit.</li>
     * </ol>
     *
     * @param user
     *            the user whose login time is being checked.
     * @param minutes
     *            the number of minutes the user has been logged in.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if an thread is interrupted.
     */
    private void logoutIfKidLoggedInTooLong(final String user,
            final long minutes) throws IOException, InterruptedException {
        switch (user) {
        case "[kid accountname 1]":
        case "[kid accountname 2]":
            if (minutes > 59) { /* 1 hour limit; adjust as needed */
                LOGGER.info("Logging out user " + user);
                ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh",
                        "-c", "./logout-user.sh " + user);
                processBuilder.directory(new File("[complete path of folder containing logout-user.sh]"));
                Process process = processBuilder.start();

                try (final BufferedReader reader = new BufferedReader(
                        new InputStreamReader(process.getInputStream()))) {
                    String line;

                    while ((line = reader.readLine()) != null) {
                        LOGGER.info(line);
                    }

                    LOGGER.debug(process.waitFor());
                }
            }

            break;
        default:
            LOGGER.debug("Time limits not enforced for user " + user);
        }
    }

For the last part of the Java coding, the log4j2.xml file is added to src/main/resources, using a fairly minimal configuration for a rolling file appender as follows.

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="DEBUG">
    <Appenders>

        <Console name="Console" target="SYSTEM_OUT">
            <PattternLayout pattern="%d %p %C{1.} [%t] %m%n" />
        </Console>

        <RollingFile name="RollingFile"
            fileName="[Full path to log folder]/logtimer.log"
            filePattern="[Full path to log folder]/archived/logtimer-%d{yyyy-MM-dd}.%i.log">
            <PatternLayout>
                <Pattern>%d %p %C{1.} [%t] %m%n</Pattern>
            </PatternLayout>
            <Policies>
                <TimeBasedTriggeringPolicy />
                <SizeBasedTriggeringPolicy
                    size="10 MB" />
            </Policies>
        </RollingFile>
    </Appenders>
    <Loggers>
        <!-- LOG everything at INFO level -->
        <Root level="info">
            <AppenderRef ref="RollingFile" />
            <AppenderRef ref="Console" />
        </Root>

        <!-- LOG "biz.noip.johnwatne*" at DEBUG level -->
        <Logger name="biz.noip.johnwatne" level="debug"
            additivity="false">
            <AppenderRef ref="RollingFile" />
            <AppenderRef ref="Console" />
        </Logger>

    </Loggers>

</Configuration>

Finally, I edited my root crontab file to add the following line, running the new job every 5 minutes.

*/5 * * * * /usr/bin/java -jar [full path to folder containing jar file]/logtimer-0.0.1-SNAPSHOT.jar > null 

The superuser scheduled jobs can then be updated (if you already have such jobs) or created by running the following command in the Terminal:

sudo crontab [name of crontab file]

Note that the crontab file should be plain text – make sure you do not put in any formatting characters with a text editor.

With everything up and running, a look at the log file looks something like the following, which you can find by opening your log file in the “Console” application in the Applications > Utilities folder.

2021-06-29 00:00:03,093 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[kid1], getTty()=console, getLoginTime()=2021 Jun 28 16:47]
2021-06-29 00:00:03,128 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[kid2], getTty()=console, getLoginTime()=2021 Jun 28 09:10]
2021-06-29 00:00:03,129 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[kid2], getTty()=console, getLoginTime()=2021 Jun 28 08:10]
2021-06-29 00:00:03,131 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[kid1], getTty()=console, getLoginTime()=2021 Jun 27 15:25]
2021-06-29 00:00:03,132 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[kid1], getTty()=console, getLoginTime()=2021 Jun 27 14:25]
2021-06-29 00:00:03,133 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[adult], getTty()=console, getLoginTime()=2021 Jun 26 11:46]
2021-06-29 00:00:03,134 DEBUG b.n.j.l.LogtimerApp [main] UserLoginTime [getUser()=[adult], getTty()=ttys000, getLoginTime()=2021 Jun 26 11:46]
2021-06-29 00:00:03,134 DEBUG b.n.j.l.LogtimerApp [main] 0
2021-06-29 00:00:03,135 DEBUG b.n.j.l.LogtimerApp [main] *** User login lists ****
2021-06-29 00:00:03,136 INFO b.n.j.l.LogtimerApp [main] *** Maximum login: UserLoginTime [getUser()=[kid2], getTty()=console, getLoginTime()=2021 Jun 28 09:10]
2021-06-29 00:00:03,142 DEBUG b.n.j.l.LogtimerApp [main] Elapsed time: 890 minutes
2021-06-29 00:00:03,143 INFO b.n.j.l.LogtimerApp [main] Logging out user [kid2]
2021-06-29 00:00:03,251 DEBUG b.n.j.l.LogtimerApp [main] 0
2021-06-29 00:00:03,252 INFO b.n.j.l.LogtimerApp [main] *** Maximum login: UserLoginTime [getUser()=[adult], getTty()=console, getLoginTime()=2021 Jun 26 11:46]
2021-06-29 00:00:03,252 DEBUG b.n.j.l.LogtimerApp [main] Elapsed time: 3614 minutes
2021-06-29 00:00:03,253 DEBUG b.n.j.l.LogtimerApp [main] Time limits not enforced for user [adult]
2021-06-29 00:00:03,254 INFO b.n.j.l.LogtimerApp [main] *** Maximum login: UserLoginTime [getUser()=[kid1], getTty()=console, getLoginTime()=2021 Jun 28 16:47]
2021-06-29 00:00:03,254 DEBUG b.n.j.l.LogtimerApp [main] Elapsed time: 433 minutes
2021-06-29 00:00:03,254 INFO b.n.j.l.LogtimerApp [main] Logging out user [kid1]
2021-06-29 00:00:03,347 DEBUG b.n.j.l.LogtimerApp [main] 0

So, it DOES log the kids out after they have been signed on for an hour. Why the 433 or 890 minutes entries, then? It turns out that, even though they have been logged out, they still appear in the output of the “who” command, with their most recent login time still shown. A future refinement of this job should include looking at the output of the “ps -u” command to see if they have any running processes and, if not, not bother to do the login check on them.

Crafty minds can probably already spot one loophole with how this works. The kids will only get logged out from their current session if the current session has been an hour or more. There is nothing checking their usage for the day. So, if they want to get the most time, they could log in for, say, 50 minutes, and then log in a few minutes later for another 50 minutes or so, and so forth. The fix will involve a more complex refinement. I think I will need to create a small database of total minutes logged in per day and user (for the monitored kids, not the adults), and add 5 minutes for every time they show up as being logged in by the every-5-minute-run of LogTimerApp. Then, if the total time for the day hits the hour limit, then do the logout.

I hope other parents trying to limit the screen time of their kids on their Mac may find this helpful and not too hard to adapt and implement on their own.