Skip to content

Commit

Permalink
last login / last ip feature
Browse files Browse the repository at this point in the history
  • Loading branch information
listerr committed Apr 17, 2023
1 parent 1363dbc commit f63d97c
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 19 deletions.
237 changes: 237 additions & 0 deletions README_LAST_ACCESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# Show Last Access / IP

With dovecot's [last_login plugin](https://doc.dovecot.org/configuration_manual/lastlogin_plugin/)
enabled, we can get information on the last access time and IP address and display this for each
application specific password.

**This feature is a work in progress and may change / disappear.**

## Dovecot configuration

`/etc/dovecot/conf.d/10-last-login.conf:`

```
plugin {
last_login_dict = proxy::sql
last_login_key = last-login/%{service}/%{user}/%{remote_ip}/%{userdb:ap_id:0}
last_login_precision = s
}
dict {
sql = mysql:/etc/dovecot/dovecot-dict-sql.conf.ext
}
```

`/etc/dovecot/dovecot-dict-sql.conf.ext:`

```
connect = host=<host> dbname=<database_name> user=<database_user> password=<database_password>
map {
pattern = shared/last-login/$service/$user/$remote_ip/$ap_id
table = last_login
value_field = last_access
value_type = uint
fields {
userid = $user
service = $service
last_ip = $remote_ip
ap_id = $ap_id
}
}
```

DB Schema (MySQL):

```
CREATE TABLE `last_login` (
`userid` VARCHAR(255) NOT NULL COLLATE 'utf8mb4_unicode_ci',
`service` VARCHAR(10) NOT NULL COLLATE 'utf8mb4_unicode_ci',
`last_access` BIGINT(19) NOT NULL,
`last_ip` VARCHAR(40) NOT NULL COLLATE 'utf8mb4_unicode_ci',
`ap_id` INT(10) NOT NULL,
PRIMARY KEY (`userid`, `service`, `ap_id`, `last_ip`) USING BTREE
)
COLLATE='utf8mb4_unicode_ci'
ENGINE=InnoDB
;
```

I could not get the last_login plugin / dovecot's dict to update the `last_ip`
column (as suggested in their example). It only seems to set `last_ip` when
the row is first created. On subsequent access, it only updates the `last_access`
column.

The 'hack' for this (at the moment) is to set userid, service, ap_id, and last_ip
as the primary key. This means `last_ip` will be updated, but there will be
multiple rows per user (not ideal, but the only way I could make it work!).

Dovecot dict does something like:

```
INSERT INTO last_login (last_access,service,userid,last_ip,ap_id) VALUES (1681695393,'imap','[email protected]','192.168.0.10','20') ON DUPLICATE KEY UPDATE last_access=1681695393
```

So only last_access is updated on subsequent logins.

Next, the passdb lookup for application passwords needs to return the id of the application password.
(In this case returning `ap_id` to `userdb_ap_id`)

```
# Database driver: mysql, pgsql, sqlite
driver = mysql
connect = host=<host> dbname=<database_name> user=<database_user> password=<database_password>
default_pass_scheme = SHA512
# Use the same username everywhere,
# select by password:
password_query = \
SELECT username, password, id as userdb_ap_id \
FROM application_passwords \
WHERE username='%u' \
AND password = SHA2('%w',"512") \
AND created >= NOW() - INTERVAL 2 MONTH;
```

Here is a slightly fancier example where dovecot uses the table p_mailbox for regular
users, and for application passwords, we join this table to get extra properties.
(p_mailbox is adopted from postfixadmin's schema.) If a user is deactivated or
removed from p_mailbox, then all the application passwords will also stop working.

```
password_query = \
SELECT ap.username AS username, \
ap.password AS password, \
ap.id AS userdb_ap_id \
FROM \
roundcubemail.application_passwords ap INNER JOIN \
roundcubemail.p_mailbox mb ON mb.username = ap.username \
WHERE ap.username='%u' \
AND mb.active = 1 \
AND ap.password = SHA2('%w',"512") \
AND ap.created >= NOW() - INTERVAL 2 MONTH;
```

After changing the configuration, run `doveadm reload`

You should start to see dovecot adding entries to the table:

```
mysql> select * from last_login;
+--------------------+---------+-------------+---------------------+-------+
| userid | service | last_access | last_ip | ap_id |
+--------------------+---------+-------------+---------------------+-------+
| [email protected] | imap | 1681699293 | 2001:db8:10:1aa::1 | 0 |
| [email protected] | imap | 1681698108 | 172.16.49.146 | 20 |
| [email protected] | imap | 1681697493 | 31.94.5.149 | 0 |
| [email protected] | imap | 1681698393 | 31.94.5.149 | 19 |
+--------------------+---------+-------------+---------------------+-------+
```

## Roundcube ap4rc configuration

`config.inc.php`

```php
// Enable show last access/IP columns
// (see README_LAST_ACCESS.md to get this working)
$config['ap4rc_show_last_access'] = true;

// Table to get ap_id from (if not last_login):
// $config['ap4rc_last_access_table'] = 'last_login';
```


![ap4rc plugin screenshot](img/ap4rc_last_login.png)

## Data cleanup

Periodically clean up "duplicate" old rows.

Note that this will delete all the rows with `ap_id` = `0`
(login not with application password).

```sql
-- Delete all last_login rows where application_password id no longer exists:
DELETE FROM last_login WHERE ap_id NOT IN ( select id from application_passwords);

-- Keep only most recent (highest) last login entry for each ap_id:
DELETE t1 FROM last_login t1
INNER JOIN last_login t2
WHERE
t1.last_access < t2.last_access AND
t1.ap_id = t2.ap_id;
```

## TODO

- Configurable date format.
- Purge old IPs from last_login.


## Postfixadmin mailbox table:

In case you are interested.

I don't currently use postfixadmin, but decided to use the same table schema, as there
are a few nice examples and tutorials for getting dovecot working with postfixadmin's
schema (not just MySQL). Postfixadmin is essentially just a database and can work
with any MTA, although is geared towards the postfix way of doing things.
Some of it may not be relevant if you are using dovecot+sieve for vacation
/ auto reply, for example.

Instead of a separate database, I put this into roundcube's database, prefixed
with `p_`.

```sql
CREATE TABLE `p_mailbox` (
`username` VARCHAR(255) NOT NULL COLLATE 'latin1_general_ci',
`password` VARCHAR(255) NOT NULL COLLATE 'latin1_general_ci',
`name` VARCHAR(255) NOT NULL COLLATE 'utf8mb4_0900_ai_ci',
`maildir` VARCHAR(255) NOT NULL COLLATE 'latin1_general_ci',
`quota` BIGINT(19) NOT NULL DEFAULT '0',
`local_part` VARCHAR(255) NOT NULL COLLATE 'latin1_general_ci',
`domain` VARCHAR(255) NOT NULL COLLATE 'latin1_general_ci',
`created` DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00',
`modified` DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00',
`active` TINYINT(1) NOT NULL DEFAULT '1',
`phone` VARCHAR(30) NOT NULL DEFAULT '' COLLATE 'utf8mb4_0900_ai_ci',
`email_other` VARCHAR(255) NOT NULL DEFAULT '' COLLATE 'utf8mb4_0900_ai_ci',
`token` VARCHAR(255) NOT NULL DEFAULT '' COLLATE 'utf8mb4_0900_ai_ci',
`token_validity` DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00',
`password_expiry` DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00',
PRIMARY KEY (`username`) USING BTREE,
INDEX `domain` (`domain`) USING BTREE
)
COMMENT='Postfix Admin - Virtual Mailboxes'
COLLATE='latin1_general_ci'
ENGINE=InnoDB
;
```
Example dovecot query to lookup from the above.
(Pretty much same as postfixadm example)

`/etc/dovecot/dovecot-sql-users.conf.ext:`

```
driver = mysql
connect = host=<host> dbname=<database_name> user=<database_user> password=<database_password>
default_pass_scheme = BLF-CRYPT
password_query = \
SELECT username, \
password \
FROM p_mailbox \
WHERE username = '%u' AND active='1';
user_query = \
SELECT username, \
CONCAT('/var/mailboxes/', maildir) AS home, \
CONCAT('*:bytes=', quota) AS quota_rule \
FROM p_mailbox \
WHERE username = '%u' AND active='1';
# Query to get a list of all usernames.
iterate_query = SELECT username FROM p_mailbox WHERE active = '1';
```
81 changes: 62 additions & 19 deletions ap4rc.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class ap4rc extends rcube_plugin
private $user_lookup_field;
private $user_lookup_data;
private $strict_userid_lookup;
private $show_last_access;
private $last_access_table;
private $username_format;
private $show_application;
private $application_name_characters;
Expand All @@ -65,6 +67,8 @@ function startup($args)
$this->aid_pad = $rcmail->config->get('ap4rc_aid_pad', 4);
$this->username_format = $rcmail->config->get('ap4rc_username_format', 1);
$this->show_application = $rcmail->config->get('ap4rc_show_application', 'auto');
$this->show_last_access = $rcmail->config->get('ap4rc_show_last_access', false);
$this->last_access_table = $rcmail->config->get('ap4rc_last_access_table', 'last_login');
$this->strict_userid_lookup = $rcmail->config->get('ap4rc_strict_userid_lookup', false);
$this->application_name_characters = $rcmail->config->get('ap4rc_application_name_characters', "a-zA-Z0-9._+-");
$this->generated_password_length = $rcmail->config->get('ap4rc_generated_password_length', 64);
Expand Down Expand Up @@ -135,6 +139,7 @@ public function settings_list($attrib = array())
$application_passwords = array();
$cols = 3;
$cols = $this->show_application ? ($cols +1) : $cols;
$cols = $this->show_last_access ? ($cols +2) : $cols;

$db = $rcmail->get_dbh();
$db_table = $db->table_name('application_passwords', true);
Expand All @@ -160,6 +165,13 @@ public function settings_list($attrib = array())

$table->add_header('name', $this->gettext('new_username'));
if ($this->show_application) { $table->add_header('application', $this->gettext('application')); }
if ($this->show_last_access) {

$table->add_header('last_access', $this->gettext('last_access'));
$table->add_header('last_ip', $this->gettext('last_ip'));

}

$table->add_header('expiry_date', $this->gettext('expiry_date'));
$table->add_header('actions', '');

Expand All @@ -176,7 +188,13 @@ public function settings_list($attrib = array())

$table->add(null, $this->application_username($record['application'], $record['id']));
if ($this->show_application) { $table->add(null, $record['application']); }
$table->add($css_class, $record['expiry']);
if ($this->show_last_access) {
$last_access = $this->get_last_access($record['id']);
$table->add(null, $last_access['last_access']);
$table->add(null, $last_access['last_ip']);
}

$table->add($css_class, $record['expiry']);

$delete_link = html::tag('a',
array(
Expand Down Expand Up @@ -239,12 +257,12 @@ public function settings_save()
$rcmail->get_user_name(),
$application,
$hashed_password,
$rcmail->get_user_id()
);
$rcmail->get_user_id()
);

// This code will only be reached if we did not see a duplicate entry exception
if ($result && $db->affected_rows($result) > 0) {
$this->new_id = $db->insert_id();
// This code will only be reached if we did not see a duplicate entry exception
if ($result && $db->affected_rows($result) > 0) {
$this->new_id = $db->insert_id();
$this->new_password = $new_password;
$this->new_application = $application;
$this->password_save_error = null;
Expand Down Expand Up @@ -316,23 +334,25 @@ public function show_new_password () {

private function application_username($appname, $appid) {

$rcmail = rcmail::get_instance();
$username = $rcmail->get_user_name();
$rcmail = rcmail::get_instance();
$username = $rcmail->get_user_name();

switch ($this->username_format) {
switch ($this->username_format) {

case 2:
return $username;
case 2:
return $username;

case 3:
return strstr($username, '@',true) . '-' . str_pad($appid, $this->aid_pad, '0', STR_PAD_LEFT) . strstr($username, '@');
case 3:
return strstr($username, '@',true) . '-' . str_pad($appid, $this->aid_pad, '0', STR_PAD_LEFT) . strstr($username, '@');

case 4:
return strtoupper(substr($username, 0, 2)) . str_pad($appid, $this->aid_pad, '0', STR_PAD_LEFT) . strstr($username, '@');

default:
return $username . '@' . $appname;

}

case 4:
return strtoupper(substr($username, 0, 2)) . str_pad($appid, $this->aid_pad, '0', STR_PAD_LEFT) . strstr($username, '@');

default:
return $username . '@' . $appname;
}
}

public function settings_apppassadder($attrib)
Expand Down Expand Up @@ -392,4 +412,27 @@ private function random_password()
}
return $randomString;
}

private function get_last_access($ap_id)
{

$rcmail = rcmail::get_instance();
$db = $rcmail->get_dbh();
$db_table = $db->table_name($this->last_access_table, true);

$result = $db->query( "
SELECT
`last_access`, `last_ip` FROM $db_table WHERE `ap_id` = ?
ORDER BY last_access DESC LIMIT 1
",
$ap_id );
$record = $db->fetch_assoc($result);

$last_access = !empty($record['last_access']) ? date("Y-m-d H:i:s T", $record['last_access']) : 'none';
$last_ip = !empty($record['last_ip']) ? $record['last_ip'] : 'none';

return compact('last_access', 'last_ip');

}

}
Binary file added img/ap4rc_last_login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions localization/de_DE.inc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ $labels['popup_successful_deletion'] = 'Eintrag erfolgreich gelöscht';
$labels['missingapplicationname'] = 'Bitte geben Sie einen gültigen Anwendungsnamen ein.';

$labels['application'] = 'Anwendung';
$labels['last_access'] = 'Letzter Zugriff';
$labels['last_ip'] = 'Letzte IP';
$labels['creation_date'] = 'Erstellt am';
$labels['expiry_date'] = 'Läuft ab';
$labels['created'] = 'Erstellt';
Expand Down
2 changes: 2 additions & 0 deletions localization/en_US.inc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ $labels['popup_successful_deletion'] = 'Successfully deleted the entry';
$labels['missingapplicationname'] = 'Please enter a valid application name.';

$labels['application'] = 'Application';
$labels['last_access'] = 'Last Access';
$labels['last_ip'] = 'Last IP';
$labels['creation_date'] = 'Created at';
$labels['expiry_date'] = 'Expires at';
$labels['created'] = 'Created';
Expand Down
Loading

0 comments on commit f63d97c

Please sign in to comment.