diff --git a/nixos/modules/config/update-users-groups.pl b/nixos/modules/config/update-users-groups.pl index 75c343523e27..5236264e16b7 100644 --- a/nixos/modules/config/update-users-groups.pl +++ b/nixos/modules/config/update-users-groups.pl @@ -4,6 +4,7 @@ use File::Path qw(make_path); use File::Slurp; use Getopt::Long; use JSON; +use DateTime; # Keep track of deleted uids and gids. my $uidMapFile = "/var/lib/nixos/uid-map"; @@ -22,6 +23,22 @@ sub updateFile { write_file($path, { atomic => 1, binmode => ':utf8', perms => $perms // 0644 }, $contents) or die; } +# Converts an ISO date to number of days since 1970-01-01 +sub dateToDays { + my ($date) = @_; + my ($year, $month, $day) = split('-', $date, -3); + my $dt = DateTime->new( + year => $year, + month => $month, + day => $day, + hour => 0, + minute => 0, + second => 0, + time_zone => 'UTC', + ); + return $dt->epoch / 86400; +} + sub nscdInvalidate { system("nscd", "--invalidate", $_[0]) unless $is_dry; } @@ -285,22 +302,26 @@ my %shadowSeen; foreach my $line (-f "/etc/shadow" ? read_file("/etc/shadow", { binmode => ":utf8" }) : ()) { chomp $line; - my ($name, $hashedPassword, @rest) = split(':', $line, -9); - my $u = $usersOut{$name};; + # struct name copied from `man 3 shadow` + my ($sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) = split(':', $line, -9); + my $u = $usersOut{$sp_namp};; next if !defined $u; - $hashedPassword = "!" if !$spec->{mutableUsers}; - $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword} && !$spec->{mutableUsers}; # FIXME - chomp $hashedPassword; - push @shadowNew, join(":", $name, $hashedPassword, @rest) . "\n"; - $shadowSeen{$name} = 1; + $sp_pwdp = "!" if !$spec->{mutableUsers}; + $sp_pwdp = $u->{hashedPassword} if defined $u->{hashedPassword} && !$spec->{mutableUsers}; # FIXME + $sp_expire = dateToDays($u->{expires}) if defined $u->{expires}; + chomp $sp_pwdp; + push @shadowNew, join(":", $sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) . "\n"; + $shadowSeen{$sp_namp} = 1; } foreach my $u (values %usersOut) { next if defined $shadowSeen{$u->{name}}; my $hashedPassword = "!"; $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword}; + my $expires = ""; + $expires = dateToDays($u->{expires}) if defined $u->{expires}; # FIXME: set correct value for sp_lstchg. - push @shadowNew, join(":", $u->{name}, $hashedPassword, "1::::::") . "\n"; + push @shadowNew, join(":", $u->{name}, $hashedPassword, "1::::", $expires, "") . "\n"; } updateFile("/etc/shadow", \@shadowNew, 0640); diff --git a/nixos/modules/config/users-groups.nix b/nixos/modules/config/users-groups.nix index 4c9e286ea5fd..9629e3964c96 100644 --- a/nixos/modules/config/users-groups.nix +++ b/nixos/modules/config/users-groups.nix @@ -311,6 +311,17 @@ let ''; }; + expires = mkOption { + type = types.nullOr (types.strMatching "[[:digit:]]{4}-[[:digit:]]{2}-[[:digit:]]{2}"); + default = null; + description = lib.mdDoc '' + Set the date on which the user's account will no longer be + accessible. The date is expressed in the format YYYY-MM-DD, or null + to disable the expiry. + A user whose account is locked must contact the system + administrator before being able to use the system again. + ''; + }; }; config = mkMerge @@ -438,7 +449,7 @@ let name uid group description home homeMode createHome isSystemUser password passwordFile hashedPassword autoSubUidGidRange subUidRanges subGidRanges - initialPassword initialHashedPassword; + initialPassword initialHashedPassword expires; shell = utils.toShellPath u.shell; }) cfg.users; groups = attrValues cfg.groups; @@ -637,7 +648,7 @@ in { install -m 0700 -d /root install -m 0755 -d /home - ${pkgs.perl.withPackages (p: [ p.FileSlurp p.JSON ])}/bin/perl \ + ${pkgs.perl.withPackages (p: [ p.FileSlurp p.JSON p.DateTime ])}/bin/perl \ -w ${./update-users-groups.pl} ${spec} ''; }; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 19aaac694594..ba18c65ca6f7 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -831,6 +831,7 @@ in { uptime-kuma = handleTest ./uptime-kuma.nix {}; usbguard = handleTest ./usbguard.nix {}; user-activation-scripts = handleTest ./user-activation-scripts.nix {}; + user-expiry = runTest ./user-expiry.nix; user-home-mode = handleTest ./user-home-mode.nix {}; uwsgi = handleTest ./uwsgi.nix {}; v2ray = handleTest ./v2ray.nix {}; diff --git a/nixos/tests/user-expiry.nix b/nixos/tests/user-expiry.nix new file mode 100644 index 000000000000..bcaed7a0ccb0 --- /dev/null +++ b/nixos/tests/user-expiry.nix @@ -0,0 +1,70 @@ +let + alice = "alice"; + bob = "bob"; + eve = "eve"; + passwd = "pass1"; +in +{ + name = "user-expiry"; + + nodes = { + machine = { + users.users = { + ${alice} = { + initialPassword = passwd; + isNormalUser = true; + expires = "1990-01-01"; + }; + ${bob} = { + initialPassword = passwd; + isNormalUser = true; + expires = "2990-01-01"; + }; + ${eve} = { + initialPassword = passwd; + isNormalUser = true; + }; + }; + }; + }; + + testScript = '' + def switch_to_tty(tty_number): + machine.fail(f"pgrep -f 'agetty.*tty{tty_number}'") + machine.send_key(f"alt-f{tty_number}") + machine.wait_until_succeeds(f"[ $(fgconsole) = {tty_number} ]") + machine.wait_for_unit(f"getty@tty{tty_number}.service") + machine.wait_until_succeeds(f"pgrep -f 'agetty.*tty{tty_number}'") + + + machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("getty@tty1.service") + + with subtest("${alice} cannot login"): + machine.wait_until_tty_matches("1", "login: ") + machine.send_chars("${alice}\n") + machine.wait_until_tty_matches("1", "Password: ") + machine.send_chars("${passwd}\n") + + machine.wait_until_succeeds("journalctl --grep='account ${alice} has expired \\(account expired\\)'") + machine.wait_until_tty_matches("1", "login: ") + + with subtest("${bob} can login"): + switch_to_tty(2) + machine.wait_until_tty_matches("2", "login: ") + machine.send_chars("${bob}\n") + machine.wait_until_tty_matches("2", "Password: ") + machine.send_chars("${passwd}\n") + + machine.wait_until_succeeds("pgrep -u ${bob} bash") + + with subtest("${eve} can login"): + switch_to_tty(3) + machine.wait_until_tty_matches("3", "login: ") + machine.send_chars("${eve}\n") + machine.wait_until_tty_matches("3", "Password: ") + machine.send_chars("${passwd}\n") + + machine.wait_until_succeeds("pgrep -u ${eve} bash") + ''; +}