Auth.pm 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. package Trog::Auth;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use FindBin::libs;
  6. use Ref::Util qw{is_arrayref};
  7. use Digest::SHA 'sha256';
  8. use Crypt::Argon2 qw{argon2id_pass argon2id_verify argon2_needs_rehash};
  9. use Trog::TOTP;
  10. use Imager::QRCode;
  11. use Trog::Utils;
  12. use Trog::Log qw{:all};
  13. use Trog::Config;
  14. use Trog::SQLite;
  15. use Trog::Data;
  16. # Ensure the db schema is OK, and give us a handle
  17. my sub dbh {
  18. my $file = 'schema/auth.schema';
  19. my $dbname = "config/auth.db";
  20. return Trog::SQLite::dbh( $file, $dbname );
  21. }
  22. =head1 Trog::Auth
  23. An SQLite3 authdb.
  24. =head1 Termination Conditions
  25. Throws exceptions in the event the session database cannot be accessed.
  26. =head1 FUNCTIONS
  27. =head2 session2user(STRING sessid) = STRING
  28. Translate a session UUID into a username.
  29. Returns empty string on no active session.
  30. =cut
  31. sub session2user ($sessid) {
  32. my $dbh = dbh();
  33. my $rows = $dbh->selectall_arrayref( "SELECT name FROM sess_user WHERE session=?", { Slice => {} }, $sessid );
  34. return '' unless ref $rows eq 'ARRAY' && @$rows;
  35. return $rows->[0]->{name};
  36. }
  37. =head2 user_has_session
  38. Return whether the user has an active session.
  39. If the user has an active session, things like password reset requests should fail when not coming from said session.
  40. =cut
  41. sub user_has_session ($user) {
  42. my $dbh = dbh();
  43. my $rows = $dbh->selectall_arrayref( "SELECT session FROM sess_user WHERE name=?", { Slice => {} }, $user );
  44. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  45. return 1;
  46. }
  47. =head2 user_exists
  48. Return whether the user exists at all.
  49. =cut
  50. sub user_exists ($user) {
  51. my $dbh = dbh();
  52. my $rows = $dbh->selectall_arrayref( "SELECT name FROM user WHERE name=?", { Slice => {} }, $user );
  53. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  54. return 1;
  55. }
  56. =head2 primary_user
  57. Returns the oldest user with the admin ACL.
  58. =cut
  59. sub primary_user {
  60. my $dbh = dbh();
  61. my $rows = $dbh->selectall_arrayref( "SELECT username FROM user_acl WHERE acl='admin' LIMIT 1", { Slice => {} } );
  62. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  63. return $rows->[0]{username};
  64. }
  65. =head2 get_existing_user_data
  66. Fetch existing settings for a user.
  67. =cut
  68. sub get_existing_user_data ($user) {
  69. my $dbh = dbh();
  70. my $rows = $dbh->selectall_arrayref( "SELECT hash, salt, totp_secret, display_name, contact_email FROM user WHERE name=?", { Slice => {} }, $user );
  71. return ( undef, undef, undef ) unless ref $rows eq 'ARRAY' && @$rows;
  72. return ( $rows->[0]{hash}, $rows->[0]{salt}, $rows->[0]{totp_secret}, $rows->[0]{display_name}, $rows->[0]{contact_email} );
  73. }
  74. =head2 email4user(STRING username) = STRING
  75. Return the associated contact email for the user.
  76. =cut
  77. sub email4user ($user) {
  78. my $dbh = dbh();
  79. my $rows = $dbh->selectall_arrayref( "SELECT contact_email FROM user WHERE name=?", { Slice => {} }, $user );
  80. return '' unless ref $rows eq 'ARRAY' && @$rows;
  81. return $rows->[0]{contact_email};
  82. }
  83. sub display2username ($display_name) {
  84. my $dbh = dbh();
  85. my $rows = $dbh->selectall_arrayref( "SELECT name FROM user WHERE display_name=?", { Slice => {} }, $display_name );
  86. return '' unless ref $rows eq 'ARRAY' && @$rows;
  87. return $rows->[0]{name};
  88. }
  89. sub username2display ($name) {
  90. my $dbh = dbh();
  91. my $rows = $dbh->selectall_arrayref( "SELECT display_name FROM user WHERE name=?", { Slice => {} }, $name );
  92. return '' unless ref $rows eq 'ARRAY' && @$rows;
  93. return $rows->[0]{display_name};
  94. }
  95. sub username2classname ($name) {
  96. # Just return the user's post UUID.
  97. state $data;
  98. state $conf;
  99. $conf //= Trog::Config::get();
  100. $data //= Trog::Data->new($conf);
  101. state @userposts = $data->get( tags => ['about'], acls => [qw{admin}] );
  102. # Users are always self-authored, you see
  103. my $user_obj = List::Util::first { ( $_->{user} || '' ) eq $name } @userposts;
  104. my $NNname = $user_obj->{id} || '';
  105. $NNname =~ tr/-/_/;
  106. return "a_$NNname";
  107. }
  108. =head2 acls4user(STRING username) = ARRAYREF
  109. Return the list of ACLs belonging to the user.
  110. The function of ACLs are to allow you to access content tagged 'private' which are also tagged with the ACL name.
  111. The 'admin' ACL is the only special one, as it allows for authoring posts, configuring tCMS, adding series (ACLs) and more.
  112. =cut
  113. sub acls4user ($username) {
  114. my $dbh = dbh();
  115. my $records = $dbh->selectall_arrayref( "SELECT acl FROM user_acl WHERE username = ?", { Slice => {} }, $username );
  116. return () unless ref $records eq 'ARRAY' && @$records;
  117. my @acls = map { $_->{acl} } @$records;
  118. return \@acls;
  119. }
  120. my sub _totp {
  121. state $totp;
  122. if ( !$totp ) {
  123. $totp = Trog::TOTP->new();
  124. die "Cannot instantiate TOTP client!" unless $totp;
  125. $totp->{DEBUG} = 1 if is_debug();
  126. }
  127. return $totp;
  128. }
  129. =head2 totp(user, domain)
  130. Enable TOTP 2fa for the specified user, or if already enabled return the existing info.
  131. Returns a QR code and URI for pasting into authenticator apps.
  132. Refuses a user or domain that would not be safe as a filename, as the QR code
  133. is written to totp/<user>@<domain>.bmp.
  134. =cut
  135. sub totp ( $user, $domain ) {
  136. # The QR is written to totp/$user@$domain.bmp, so both halves end up in a
  137. # path. Nothing constrains a username -- tcms-useradd will take whatever
  138. # it is given -- so one containing a slash or .. would otherwise write
  139. # outside totp/ entirely. Same whitelist Trog::DataSource::Virt uses for
  140. # guest names.
  141. my ($safe_user) = ( $user // '' ) =~ m/^([A-Za-z0-9._-]+)$/;
  142. my ($safe_domain) = ( $domain // '' ) =~ m/^([A-Za-z0-9._-]+)$/;
  143. return ( undef, undef, 1, "Refusing to build a TOTP QR for unsafe username '" . ( $user // '' ) . "'." ) unless $safe_user;
  144. return ( undef, undef, 1, "Refusing to build a TOTP QR for unsafe domain '" . ( $domain // '' ) . "'." ) unless $safe_domain;
  145. my $totp = _totp();
  146. my $dbh = dbh();
  147. my $failure = 0;
  148. my $message = "TOTP Secret generated successfully.";
  149. # Make sure we re-generate the same one in case the user forgot.
  150. my $secret;
  151. my $worked = $dbh->selectall_arrayref( "SELECT totp_secret FROM user WHERE name = ?", { Slice => {} }, $user );
  152. if ( ref $worked eq 'ARRAY' && @$worked ) {
  153. $secret = $worked->[0]{totp_secret};
  154. }
  155. $failure = -1 if $secret;
  156. # Generate a new secret if needed
  157. my $secret_is_generated = 0;
  158. if ( !$secret ) {
  159. $secret_is_generated = 1;
  160. $totp->_valid_secret();
  161. $secret = $totp->secret();
  162. }
  163. my $uri = $totp->generate_otp(
  164. user => "$user\@$domain",
  165. issuer => $domain,
  166. #XXX verifier apps will only do 30s :(
  167. period => 30,
  168. digits => 6,
  169. secret => $secret,
  170. );
  171. my $qr = "$safe_user\@$safe_domain.bmp";
  172. if ($secret_is_generated) {
  173. # Liquidate the QR code if it's already there. Unconditional: unlink
  174. # returns 0 for a file that was never there, it does not warn or die.
  175. unlink "totp/$qr";
  176. eval { $dbh->do( "UPDATE user SET totp_secret=? WHERE name=?", undef, $secret, $user ); 1 } or do {
  177. WARN("Could not store the TOTP secret of $user: $@");
  178. return ( undef, undef, 1, "Failed to store TOTP secret." );
  179. };
  180. }
  181. # This is subsequently served via authenticated _serve() in TCMS.pm.
  182. # Plotted every time rather than only when the file is missing: the QR is a
  183. # pure function of the secret, so rewriting it produces the same bytes, and
  184. # the file was just unlinked above whenever the secret changed.
  185. my $qrcode = Imager::QRCode->new(
  186. size => 4,
  187. margin => 3,
  188. level => 'L',
  189. casesensitive => 1,
  190. lightcolor => Imager::Color->new( 255, 255, 255 ),
  191. darkcolor => Imager::Color->new( 0, 0, 0 ),
  192. );
  193. my $img = $qrcode->plot($uri);
  194. $img->write( file => "totp/$qr", type => "bmp" ) or return ( undef, undef, 1, "Could not write totp/$qr: " . $img->errstr );
  195. return ( $uri, $qr, $failure, $message, $totp );
  196. }
  197. =head2 has_totp(user) = BOOL
  198. Whether this user has enrolled in TOTP yet.
  199. Everybody has to, so this is the question the dispatcher asks on the way to
  200. every page a login can reach: a user who has not is sent to /totp and can go
  201. nowhere else until they have.
  202. There is no way to turn it back off. Losing an authenticator is not a reason to
  203. stop having a second factor, and the answer to it is bin/totp, which re-reads a
  204. user their enrolment out of band -- the secret is still in the database, so it
  205. is the same enrolment rather than a new one.
  206. =cut
  207. sub has_totp ($user) {
  208. my $dbh = dbh();
  209. my $rows = $dbh->selectall_arrayref( "SELECT totp_secret FROM user WHERE name=?", { Slice => {} }, $user );
  210. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  211. return $rows->[0]{totp_secret} ? 1 : 0;
  212. }
  213. =head2 spend_totp(user, code) = ($ok, $why)
  214. Check a TOTP code and spend it. A code that has been spent, or that is older
  215. than one this user has already spent, is refused however valid the arithmetic
  216. says it is.
  217. This is the difference between a second factor at the door and a second factor
  218. that authorizes a particular thing. A code read over somebody's shoulder, or
  219. left in a proxy log, is good for the rest of its window otherwise -- and what it
  220. would be good for is releasing a stored password, which is not a window anybody
  221. should be relaxed about.
  222. The cost is that a code is worth one action. Logging in and then immediately
  223. asking for a secret means waiting for the next one, which is thirty seconds and
  224. is the point.
  225. Tolerance is one step either way, for clocks that disagree a little. It used to
  226. be three, which was ninety seconds of a code staying good, and there is no
  227. reason for that once a code cannot be used twice anyway.
  228. =cut
  229. our $totp_tolerance = 1;
  230. our $totp_period = 30;
  231. our $totp_digits = 6;
  232. sub spend_totp ( $user, $code ) {
  233. return ( 0, 'no code given' ) unless defined $code && $code =~ m/^[0-9]{$totp_digits}$/;
  234. my $dbh = dbh();
  235. my $rows = $dbh->selectall_arrayref( "SELECT totp_secret FROM user WHERE name=?", { Slice => {} }, $user );
  236. my $secret = ref $rows eq 'ARRAY' && @$rows ? $rows->[0]{totp_secret} : undef;
  237. return ( 0, 'this user has no second factor enrolled' ) unless $secret;
  238. # Forced in rather than passed: expected_totp_code reads the object, and
  239. # the object generates a secret of its own if it has not got one.
  240. my $totp = _totp();
  241. $totp->{secret} = $secret;
  242. $totp->{period} = $totp_period;
  243. $totp->{digits} = $totp_digits;
  244. my $now = time();
  245. my $matched;
  246. foreach my $offset ( -$totp_tolerance .. $totp_tolerance ) {
  247. my $when = $now + ( $offset * $totp_period );
  248. next unless $totp->expected_totp_code($when) eq $code;
  249. $matched = int( $when / $totp_period );
  250. last;
  251. }
  252. if ( !defined $matched ) {
  253. log_event( 'totp_failure', $user );
  254. return ( 0, 'that code is not right' );
  255. }
  256. my $spent = $dbh->selectall_arrayref( "SELECT step FROM totp_spent WHERE username=?", { Slice => {} }, $user );
  257. my $last = ref $spent eq 'ARRAY' && @$spent ? $spent->[0]{step} : 0;
  258. if ( $matched <= $last ) {
  259. log_event( 'totp_replay', $user );
  260. return ( 0, 'that code has already been used; wait for the next one' );
  261. }
  262. eval { $dbh->do( "INSERT OR REPLACE INTO totp_spent (username, step) VALUES (?,?)", undef, $user, $matched ); 1 } or do {
  263. WARN("Could not record the TOTP step of $user as spent: $@");
  264. return ( 0, 'could not record the code as used, so it is refused' );
  265. };
  266. return ( 1, '' );
  267. }
  268. =head2 check_password(user, pass) = BOOL
  269. Whether this is the user's password, upgrading how it is stored if it is.
  270. Passwords were kept as a single unstretched sha256 of the password and a salt,
  271. which is a hash built to be fast over a thing built to be guessed: a commodity
  272. GPU walks a list of them at billions a second. They are Argon2id now, which is
  273. built to be slow and to want memory, so the same list costs real hardware and
  274. real time.
  275. Both live in the same column. An Argon2 hash says what it is in its first few
  276. characters, so a row that does not is the old kind: it is checked the old way,
  277. and then, having just been handed a correct password in plaintext for the only
  278. moment anyone ever will be, rewritten as the new kind. Nobody is logged out and
  279. nobody has to be told, and the old hashes leave as their owners come back.
  280. The same rewrite happens when the parameters here are raised, which is what
  281. makes raising them later a one-line change rather than a migration.
  282. =cut
  283. # OWASP's minimum acceptable Argon2id configuration. Deliberately not their
  284. # generous one: /auth takes a password from anybody who asks, so what is on the
  285. # other side of it is a memory allocation an unauthenticated request can ask
  286. # for, times however many workers are listening.
  287. our $argon2_time = 2;
  288. our $argon2_memory = '19456k';
  289. our $argon2_lanes = 1;
  290. our $argon2_tag = 32;
  291. our $argon2_saltlen = 16;
  292. # The hash and the salt that goes with it. Argon2 carries the salt in its own
  293. # encoding as well; the column keeps it because the rows which have not been
  294. # upgraded yet are still using it for what it was for.
  295. my sub hash_password ($pass) {
  296. my $salt = Trog::Utils::uuid();
  297. $salt = substr( $salt, 0, $argon2_saltlen );
  298. return ( argon2id_pass( $pass, $salt, $argon2_time, $argon2_memory, $argon2_lanes, $argon2_tag ), $salt );
  299. }
  300. my sub store_password ( $user, $pass ) {
  301. my ( $hash, $salt ) = hash_password($pass);
  302. my $dbh = dbh();
  303. $dbh->do( "UPDATE user SET hash=?, salt=? WHERE name=?", undef, $hash, $salt, $user );
  304. return 1;
  305. }
  306. sub check_password ( $user, $pass ) {
  307. return 0 unless $pass;
  308. my $dbh = dbh();
  309. my $rows = $dbh->selectall_arrayref( "SELECT hash, salt FROM user WHERE name = ?", { Slice => {} }, $user );
  310. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  311. my ( $hash, $salt ) = ( $rows->[0]{hash}, $rows->[0]{salt} );
  312. return 0 unless $hash;
  313. if ( index( $hash, '$argon2' ) == 0 ) {
  314. return 0 unless eval { argon2id_verify( $hash, $pass ) };
  315. # Cheap, and it is the only time we hold the password, so it is the only
  316. # time the cost of a stronger hash can be paid.
  317. store_password( $user, $pass )
  318. if argon2_needs_rehash( $hash, 'argon2id', $argon2_time, $argon2_memory, $argon2_lanes, $argon2_tag, $argon2_saltlen );
  319. return 1;
  320. }
  321. return 0 unless sha256( $pass . ( $salt // '' ) ) eq $hash;
  322. INFO("Upgrading the stored password for $user from sha256 to argon2id");
  323. store_password( $user, $pass );
  324. return 1;
  325. }
  326. =head2 mksession(user, pass, token) = STRING
  327. Create a session for the user and waste all other sessions.
  328. Returns a session ID, or blank string in the event the user does not exist or incorrect auth was passed.
  329. =cut
  330. sub mksession ( $user, $pass, $token, $ip_addr = '' ) {
  331. my $dbh = dbh();
  332. my $records = $dbh->selectall_arrayref( "SELECT name, hash, salt, totp_secret FROM user WHERE name = ?", { Slice => {} }, $user );
  333. if ( !( ref $records eq 'ARRAY' && @$records ) || !check_password( $user, $pass ) ) {
  334. INFO("Failed login for user $user");
  335. log_event( 'login_failure', $user, $ip_addr );
  336. return '';
  337. }
  338. my $uid = $records->[0]{name};
  339. my $secret = $records->[0]{totp_secret};
  340. # No secret means they have not enrolled yet, and letting them in is how
  341. # they get to -- the enrolment page is behind a login, since the QR on it is
  342. # the secret. TCMS::needs_enrolment is what stops that being a way to have
  343. # an account with no second factor: it is the only page they can reach.
  344. if ($secret) {
  345. my ( $ok, $why ) = spend_totp( $user, $token );
  346. if ( !$ok ) {
  347. INFO("TOTP auth failed for user $user: $why");
  348. log_event( 'totp_failure', $user, $ip_addr );
  349. return '';
  350. }
  351. }
  352. # Issue cookie
  353. my $uuid = Trog::Utils::uuid();
  354. $dbh->do( "INSERT OR REPLACE INTO session (id,username) VALUES (?,?)", undef, $uuid, $uid );
  355. log_event( 'login_success', $user, $ip_addr, $uuid );
  356. return $uuid;
  357. }
  358. =head2 killsession(user) = BOOL
  359. Delete the provided user's session from the auth db.
  360. =cut
  361. sub killsession ( $user, $ip_addr = '' ) {
  362. my $dbh = dbh();
  363. $dbh->do( "DELETE FROM session WHERE username=?", undef, $user );
  364. log_event( 'logout', $user, $ip_addr );
  365. return 1;
  366. }
  367. =head2 useradd(user, displayname, pass, acls, contactemail) = BOOL
  368. Adds a user identified by the provided password into the auth DB.
  369. Also used to alter users.
  370. Returns true. Dies if the user cannot be stored, as the rest of this module
  371. does when the auth database fails.
  372. =cut
  373. sub useradd ( $user, $displayname, $pass, $acls, $contactemail ) {
  374. # See if the user exists already, keep pw if nothing's passed
  375. my ( $hash, $salt, $t_secret, $dn, $ce ) = get_existing_user_data($user);
  376. $displayname //= $dn;
  377. $contactemail //= $ce;
  378. die "No username set!" unless $user;
  379. die "No display name set!" unless $displayname;
  380. die "Username and display name cannot be the same" if $user eq $displayname;
  381. die "No password set for user!" if !$pass && !$hash;
  382. die "ACLs must be array" unless is_arrayref($acls);
  383. die "No contact email set for user!" unless $contactemail;
  384. my $dbh = dbh();
  385. ( $hash, $salt ) = hash_password($pass) if $pass;
  386. $dbh->do( "INSERT OR REPLACE INTO user (name, display_name, salt, hash, contact_email, totp_secret) VALUES (?,?,?,?,?,?)", undef, $user, $displayname, $salt, $hash, $contactemail, $t_secret );
  387. #XXX this is clearly not normalized with an ACL mapping table, will be an issue with large number of users
  388. foreach my $acl (@$acls) {
  389. $dbh->do( "INSERT OR REPLACE INTO user_acl (username,acl) VALUES (?,?)", undef, $user, $acl );
  390. }
  391. return 1;
  392. }
  393. sub add_change_request (%args) {
  394. my $dbh = dbh();
  395. $dbh->do( "INSERT INTO change_request (username,token,type,secret) VALUES (?,?,?,?)", undef, $args{user}, $args{token}, $args{type}, $args{secret} );
  396. return 1;
  397. }
  398. sub process_change_request ($token) {
  399. my $dbh = dbh();
  400. my $rows = $dbh->selectall_arrayref( "SELECT username, display_name, type, secret, contact_email FROM change_request_full WHERE processed=0 AND token=?", { Slice => {} }, $token );
  401. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  402. my $user = $rows->[0]{username};
  403. my $display = $rows->[0]{display_name};
  404. my $type = $rows->[0]{type};
  405. my $secret = $rows->[0]{secret};
  406. my $contactemail = $rows->[0]{contact_email};
  407. state %dispatch = (
  408. reset_pass => sub {
  409. my ( $user, $pass ) = @_;
  410. #XXX The fact that this is an INSERT OR REPLACE means all the entries in change_request for this user will get cascade wiped. Which is good, as the secrets aren't salted.
  411. # This is also why we have to snag the user's ACLs or they will be wiped.
  412. my @acls = acls4user($user);
  413. useradd( $user, $display, $pass, \@acls, $contactemail );
  414. killsession($user);
  415. return "Password set to $pass for $user";
  416. },
  417. );
  418. my $res = $dispatch{$type}->( $user, $secret );
  419. eval { $dbh->do( "UPDATE change_request SET processed=1 WHERE token=?", undef, $token ); 1 } or do {
  420. FATAL("Could not set job with token $token to completed: $@");
  421. };
  422. return $res;
  423. }
  424. =head2 audit_log(%opts) = ARRAYREF
  425. Return recent authentication events.
  426. Supports filtering by C<username>, C<event_type>, and C<limit> (default 100).
  427. =cut
  428. sub audit_log (%opts) {
  429. my $dbh = dbh();
  430. my $limit = int( $opts{limit} // 100 );
  431. my @where;
  432. my @bind;
  433. if ( $opts{username} ) {
  434. push @where, 'username = ?';
  435. push @bind, $opts{username};
  436. }
  437. if ( $opts{event_type} ) {
  438. push @where, 'event_type = ?';
  439. push @bind, $opts{event_type};
  440. }
  441. my $where = @where ? 'WHERE ' . join( ' AND ', @where ) : '';
  442. my $rows = $dbh->selectall_arrayref(
  443. ## no critic(ValuesAndExpressions::PreventSQLInjection)
  444. "SELECT id, event_time, username, session_id, ip_addr, event_type FROM audit_log $where ORDER BY event_time DESC LIMIT ?",
  445. { Slice => {} }, @bind, $limit
  446. );
  447. return [] unless ref $rows eq 'ARRAY';
  448. return $rows;
  449. }
  450. =head2 log_event($event_type, $username, $ip_addr, $session_id)
  451. Write one row to the audit log.
  452. Public because the vault writes to it too: reading somebody's stored password is
  453. the same kind of event as logging in as them, and it belongs in the same list.
  454. Swallows its own errors. A login that fails because the audit table is unhappy
  455. would be a worse outcome than a login nobody wrote down.
  456. =cut
  457. sub log_event ( $event_type, $username, $ip_addr = '', $session_id = undef ) {
  458. eval {
  459. my $dbh = dbh();
  460. $dbh->do(
  461. "INSERT INTO audit_log (event_time, username, session_id, ip_addr, event_type) VALUES (?,?,?,?,?)",
  462. undef, time(), $username, $session_id, $ip_addr, $event_type
  463. );
  464. };
  465. WARN("audit_log insert failed: $@") if $@;
  466. return;
  467. }
  468. =head2 users_with_emails() = ARRAYREF
  469. Return all users and their contact emails, for use in libravatar lookups.
  470. =cut
  471. #XXX it may be worth using the sqlite extension to do SHA hashing here to speed up lookups.
  472. sub users_with_emails {
  473. my $dbh = dbh();
  474. my $rows = $dbh->selectall_arrayref(
  475. "SELECT name, contact_email FROM user WHERE contact_email IS NOT NULL AND contact_email != ''",
  476. { Slice => {} }
  477. );
  478. return ref $rows eq 'ARRAY' ? $rows : [];
  479. }
  480. 1;