Auth.pm 21 KB

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