Auth.pm 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  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 Trog::TOTP;
  8. use Imager::QRCode;
  9. use Trog::Utils;
  10. use Trog::Log qw{:all};
  11. use Trog::Config;
  12. use Trog::SQLite;
  13. use Trog::Data;
  14. =head1 Trog::Auth
  15. An SQLite3 authdb.
  16. =head1 Termination Conditions
  17. Throws exceptions in the event the session database cannot be accessed.
  18. =head1 FUNCTIONS
  19. =head2 session2user(STRING sessid) = STRING
  20. Translate a session UUID into a username.
  21. Returns empty string on no active session.
  22. =cut
  23. sub session2user ($sessid) {
  24. my $dbh = _dbh();
  25. my $rows = $dbh->selectall_arrayref( "SELECT name FROM sess_user WHERE session=?", { Slice => {} }, $sessid );
  26. return '' unless ref $rows eq 'ARRAY' && @$rows;
  27. return $rows->[0]->{name};
  28. }
  29. =head2 user_has_session
  30. Return whether the user has an active session.
  31. If the user has an active session, things like password reset requests should fail when not coming from said session.
  32. =cut
  33. sub user_has_session ($user) {
  34. my $dbh = _dbh();
  35. my $rows = $dbh->selectall_arrayref( "SELECT session FROM sess_user WHERE name=?", { Slice => {} }, $user );
  36. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  37. return 1;
  38. }
  39. =head2 user_exists
  40. Return whether the user exists at all.
  41. =cut
  42. sub user_exists ($user) {
  43. my $dbh = _dbh();
  44. my $rows = $dbh->selectall_arrayref( "SELECT name FROM user WHERE name=?", { Slice => {} }, $user );
  45. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  46. return 1;
  47. }
  48. =head2 primary_user
  49. Returns the oldest user with the admin ACL.
  50. =cut
  51. sub primary_user {
  52. my $dbh = _dbh();
  53. my $rows = $dbh->selectall_arrayref( "SELECT username FROM user_acl WHERE acl='admin' LIMIT 1", { Slice => {} } );
  54. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  55. return $rows->[0]{username};
  56. }
  57. =head2 get_existing_user_data
  58. Fetch existing settings for a user.
  59. =cut
  60. sub get_existing_user_data ($user) {
  61. my $dbh = _dbh();
  62. my $rows = $dbh->selectall_arrayref( "SELECT hash, salt, totp_secret, display_name, contact_email FROM user WHERE name=?", { Slice => {} }, $user );
  63. return ( undef, undef, undef ) unless ref $rows eq 'ARRAY' && @$rows;
  64. return ( $rows->[0]{hash}, $rows->[0]{salt}, $rows->[0]{totp_secret}, $rows->[0]{display_name}, $rows->[0]{contact_email} );
  65. }
  66. =head2 email4user(STRING username) = STRING
  67. Return the associated contact email for the user.
  68. =cut
  69. sub email4user ($user) {
  70. my $dbh = _dbh();
  71. my $rows = $dbh->selectall_arrayref( "SELECT contact_email FROM user WHERE name=?", { Slice => {} }, $user );
  72. return '' unless ref $rows eq 'ARRAY' && @$rows;
  73. return $rows->[0]{contact_email};
  74. }
  75. sub display2username ($display_name) {
  76. my $dbh = _dbh();
  77. my $rows = $dbh->selectall_arrayref( "SELECT name FROM user WHERE display_name=?", { Slice => {} }, $display_name );
  78. return '' unless ref $rows eq 'ARRAY' && @$rows;
  79. return $rows->[0]{name};
  80. }
  81. sub username2display ($name) {
  82. my $dbh = _dbh();
  83. my $rows = $dbh->selectall_arrayref( "SELECT display_name FROM user WHERE name=?", { Slice => {} }, $name );
  84. return '' unless ref $rows eq 'ARRAY' && @$rows;
  85. return $rows->[0]{display_name};
  86. }
  87. sub username2classname ($name) {
  88. # Just return the user's post UUID.
  89. state $data;
  90. state $conf;
  91. $conf //= Trog::Config::get();
  92. $data //= Trog::Data->new($conf);
  93. state @userposts = $data->get( tags => ['about'], acls => [qw{admin}] );
  94. # Users are always self-authored, you see
  95. my $user_obj = List::Util::first { ( $_->{user} || '' ) eq $name } @userposts;
  96. my $NNname = $user_obj->{id} || '';
  97. $NNname =~ tr/-/_/;
  98. return "a_$NNname";
  99. }
  100. =head2 acls4user(STRING username) = ARRAYREF
  101. Return the list of ACLs belonging to the user.
  102. The function of ACLs are to allow you to access content tagged 'private' which are also tagged with the ACL name.
  103. The 'admin' ACL is the only special one, as it allows for authoring posts, configuring tCMS, adding series (ACLs) and more.
  104. =cut
  105. sub acls4user ($username) {
  106. my $dbh = _dbh();
  107. my $records = $dbh->selectall_arrayref( "SELECT acl FROM user_acl WHERE username = ?", { Slice => {} }, $username );
  108. return () unless ref $records eq 'ARRAY' && @$records;
  109. my @acls = map { $_->{acl} } @$records;
  110. return \@acls;
  111. }
  112. =head2 totp(user, domain)
  113. Enable TOTP 2fa for the specified user, or if already enabled return the existing info.
  114. Returns a QR code and URI for pasting into authenticator apps.
  115. Refuses a user or domain that would not be safe as a filename, as the QR code
  116. is written to totp/<user>@<domain>.bmp.
  117. =cut
  118. sub totp ( $user, $domain ) {
  119. # The QR is written to totp/$user@$domain.bmp, so both halves end up in a
  120. # path. Nothing constrains a username -- tcms-useradd will take whatever
  121. # it is given -- so one containing a slash or .. would otherwise write
  122. # outside totp/ entirely. Same whitelist Trog::DataSource::Virt uses for
  123. # guest names.
  124. my ($safe_user) = ( $user // '' ) =~ m/^([A-Za-z0-9._-]+)$/;
  125. my ($safe_domain) = ( $domain // '' ) =~ m/^([A-Za-z0-9._-]+)$/;
  126. return ( undef, undef, 1, "Refusing to build a TOTP QR for unsafe username '" . ( $user // '' ) . "'." ) unless $safe_user;
  127. return ( undef, undef, 1, "Refusing to build a TOTP QR for unsafe domain '" . ( $domain // '' ) . "'." ) unless $safe_domain;
  128. my $totp = _totp();
  129. my $dbh = _dbh();
  130. my $failure = 0;
  131. my $message = "TOTP Secret generated successfully.";
  132. # Make sure we re-generate the same one in case the user forgot.
  133. my $secret;
  134. my $worked = $dbh->selectall_arrayref( "SELECT totp_secret FROM user WHERE name = ?", { Slice => {} }, $user );
  135. if ( ref $worked eq 'ARRAY' && @$worked ) {
  136. $secret = $worked->[0]{totp_secret};
  137. }
  138. $failure = -1 if $secret;
  139. # Generate a new secret if needed
  140. my $secret_is_generated = 0;
  141. if ( !$secret ) {
  142. $secret_is_generated = 1;
  143. $totp->_valid_secret();
  144. $secret = $totp->secret();
  145. }
  146. my $uri = $totp->generate_otp(
  147. user => "$user\@$domain",
  148. issuer => $domain,
  149. #XXX verifier apps will only do 30s :(
  150. period => 30,
  151. digits => 6,
  152. secret => $secret,
  153. );
  154. my $qr = "$safe_user\@$safe_domain.bmp";
  155. if ($secret_is_generated) {
  156. # Liquidate the QR code if it's already there. Unconditional: unlink
  157. # returns 0 for a file that was never there, it does not warn or die.
  158. unlink "totp/$qr";
  159. $dbh->do( "UPDATE user SET totp_secret=? WHERE name=?", undef, $secret, $user ) or return ( undef, undef, 1, "Failed to store TOTP secret." );
  160. }
  161. # This is subsequently served via authenticated _serve() in TCMS.pm.
  162. # Plotted every time rather than only when the file is missing: the QR is a
  163. # pure function of the secret, so rewriting it produces the same bytes, and
  164. # the file was just unlinked above whenever the secret changed.
  165. my $qrcode = Imager::QRCode->new(
  166. size => 4,
  167. margin => 3,
  168. level => 'L',
  169. casesensitive => 1,
  170. lightcolor => Imager::Color->new( 255, 255, 255 ),
  171. darkcolor => Imager::Color->new( 0, 0, 0 ),
  172. );
  173. my $img = $qrcode->plot($uri);
  174. $img->write( file => "totp/$qr", type => "bmp" ) or return ( undef, undef, 1, "Could not write totp/$qr: " . $img->errstr );
  175. return ( $uri, $qr, $failure, $message, $totp );
  176. }
  177. sub _totp {
  178. state $totp;
  179. if ( !$totp ) {
  180. $totp = Trog::TOTP->new();
  181. die "Cannot instantiate TOTP client!" unless $totp;
  182. $totp->{DEBUG} = 1 if is_debug();
  183. }
  184. return $totp;
  185. }
  186. =head2 clear_totp
  187. Clear the totp codes for provided user
  188. =cut
  189. sub clear_totp ($user) {
  190. my $dbh = _dbh();
  191. my $res = $dbh->do( "UPDATE user SET totp_secret=null WHERE name=?", undef, $user ) or die "Could not clear user TOTP secrets";
  192. return !!$res;
  193. }
  194. =head2 mksession(user, pass, token) = STRING
  195. Create a session for the user and waste all other sessions.
  196. Returns a session ID, or blank string in the event the user does not exist or incorrect auth was passed.
  197. =cut
  198. sub mksession ( $user, $pass, $token, $ip_addr = '' ) {
  199. my $dbh = _dbh();
  200. my $totp = _totp();
  201. # Check the password
  202. my $records = $dbh->selectall_arrayref( "SELECT salt FROM user WHERE name = ?", { Slice => {} }, $user );
  203. return '' unless ref $records eq 'ARRAY' && @$records;
  204. my $salt = $records->[0]->{salt};
  205. my $hash = sha256( $pass . $salt );
  206. my $worked = $dbh->selectall_arrayref( "SELECT name, totp_secret FROM user WHERE hash=? AND name = ?", { Slice => {} }, $hash, $user );
  207. if ( !( ref $worked eq 'ARRAY' && @$worked ) ) {
  208. INFO("Failed login for user $user");
  209. _log_event( 'login_failure', $user, $ip_addr );
  210. return '';
  211. }
  212. my $uid = $worked->[0]{name};
  213. my $secret = $worked->[0]{totp_secret};
  214. # Validate the 2FA Token. If we have no secret, allow login so they can see their QR code, and subsequently re-auth.
  215. if ($secret) {
  216. if ( !$token ) {
  217. _log_event( 'totp_failure', $user, $ip_addr );
  218. return '';
  219. }
  220. DEBUG( "TOTP Auth: Sent code $token, expect " . $totp->expected_totp_code(time) );
  221. #XXX we have to force the secret into compliance, otherwise it generates one on the fly, oof
  222. $totp->{secret} = $secret;
  223. my $rc = $totp->validate_otp( otp => $token, secret => $secret, tolerance => 3, period => 30, digits => 6 );
  224. if ( !$rc ) {
  225. INFO("TOTP Auth failed for user $user");
  226. _log_event( 'totp_failure', $user, $ip_addr );
  227. return '';
  228. }
  229. }
  230. # Issue cookie
  231. my $uuid = Trog::Utils::uuid();
  232. $dbh->do( "INSERT OR REPLACE INTO session (id,username) VALUES (?,?)", undef, $uuid, $uid ) or return '';
  233. _log_event( 'login_success', $user, $ip_addr, $uuid );
  234. return $uuid;
  235. }
  236. =head2 killsession(user) = BOOL
  237. Delete the provided user's session from the auth db.
  238. =cut
  239. sub killsession ( $user, $ip_addr = '' ) {
  240. my $dbh = _dbh();
  241. $dbh->do( "DELETE FROM session WHERE username=?", undef, $user );
  242. _log_event( 'logout', $user, $ip_addr );
  243. return 1;
  244. }
  245. =head2 useradd(user, displayname, pass, acls, contactemail) = BOOL
  246. Adds a user identified by the provided password into the auth DB.
  247. Also used to alter users.
  248. Returns True or False (likely false when user already exists).
  249. =cut
  250. sub useradd ( $user, $displayname, $pass, $acls, $contactemail ) {
  251. # See if the user exists already, keep pw if nothing's passed
  252. my ( $hash, $salt, $t_secret, $dn, $ce ) = get_existing_user_data($user);
  253. $displayname //= $dn;
  254. $contactemail //= $ce;
  255. die "No username set!" unless $user;
  256. die "No display name set!" unless $displayname;
  257. die "Username and display name cannot be the same" if $user eq $displayname;
  258. die "No password set for user!" if !$pass && !$hash;
  259. die "ACLs must be array" unless is_arrayref($acls);
  260. die "No contact email set for user!" unless $contactemail;
  261. my $dbh = _dbh();
  262. if ($pass) {
  263. $salt = Trog::Utils::uuid();
  264. $hash = sha256( $pass . $salt );
  265. }
  266. 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 );
  267. return unless $res && ref $acls eq 'ARRAY';
  268. #XXX this is clearly not normalized with an ACL mapping table, will be an issue with large number of users
  269. foreach my $acl (@$acls) {
  270. return unless $dbh->do( "INSERT OR REPLACE INTO user_acl (username,acl) VALUES (?,?)", undef, $user, $acl );
  271. }
  272. return 1;
  273. }
  274. sub add_change_request (%args) {
  275. my $dbh = _dbh();
  276. my $res = $dbh->do( "INSERT INTO change_request (username,token,type,secret) VALUES (?,?,?,?)", undef, $args{user}, $args{token}, $args{type}, $args{secret} );
  277. return !!$res;
  278. }
  279. sub process_change_request ($token) {
  280. my $dbh = _dbh();
  281. my $rows = $dbh->selectall_arrayref( "SELECT username, display_name, type, secret, contact_email FROM change_request_full WHERE processed=0 AND token=?", { Slice => {} }, $token );
  282. return 0 unless ref $rows eq 'ARRAY' && @$rows;
  283. my $user = $rows->[0]{username};
  284. my $display = $rows->[0]{display_name};
  285. my $type = $rows->[0]{type};
  286. my $secret = $rows->[0]{secret};
  287. my $contactemail = $rows->[0]{contact_email};
  288. state %dispatch = (
  289. reset_pass => sub {
  290. my ( $user, $pass ) = @_;
  291. #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.
  292. # This is also why we have to snag the user's ACLs or they will be wiped.
  293. my @acls = acls4user($user);
  294. useradd( $user, $display, $pass, \@acls, $contactemail ) or do {
  295. return '';
  296. };
  297. killsession($user);
  298. return "Password set to $pass for $user";
  299. },
  300. clear_totp => sub {
  301. my ($user) = @_;
  302. clear_totp($user) or do {
  303. return '';
  304. };
  305. killsession($user);
  306. return "TOTP auth turned off for $user";
  307. },
  308. );
  309. my $res = $dispatch{$type}->( $user, $secret );
  310. $dbh->do( "UPDATE change_request SET processed=1 WHERE token=?", undef, $token ) or do {
  311. FATAL("Could not set job with token $token to completed!");
  312. };
  313. return $res;
  314. }
  315. =head2 audit_log(%opts) = ARRAYREF
  316. Return recent authentication events.
  317. Supports filtering by C<username>, C<event_type>, and C<limit> (default 100).
  318. =cut
  319. sub audit_log (%opts) {
  320. my $dbh = _dbh();
  321. my $limit = int( $opts{limit} // 100 );
  322. my @where;
  323. my @bind;
  324. if ( $opts{username} ) {
  325. push @where, 'username = ?';
  326. push @bind, $opts{username};
  327. }
  328. if ( $opts{event_type} ) {
  329. push @where, 'event_type = ?';
  330. push @bind, $opts{event_type};
  331. }
  332. my $where = @where ? 'WHERE ' . join( ' AND ', @where ) : '';
  333. my $rows = $dbh->selectall_arrayref(
  334. ## no critic(ValuesAndExpressions::PreventSQLInjection)
  335. "SELECT id, event_time, username, session_id, ip_addr, event_type FROM audit_log $where ORDER BY event_time DESC LIMIT ?",
  336. { Slice => {} }, @bind, $limit
  337. );
  338. return [] unless ref $rows eq 'ARRAY';
  339. return $rows;
  340. }
  341. # Write one event row; silently swallows errors so logging never breaks auth flow.
  342. sub _log_event ( $event_type, $username, $ip_addr = '', $session_id = undef ) {
  343. eval {
  344. my $dbh = _dbh();
  345. $dbh->do(
  346. "INSERT INTO audit_log (event_time, username, session_id, ip_addr, event_type) VALUES (?,?,?,?,?)",
  347. undef, time(), $username, $session_id, $ip_addr, $event_type
  348. );
  349. };
  350. WARN("audit_log insert failed: $@") if $@;
  351. return;
  352. }
  353. =head2 users_with_emails() = ARRAYREF
  354. Return all users and their contact emails, for use in libravatar lookups.
  355. =cut
  356. #XXX it may be worth using the sqlite extension to do SHA hashing here to speed up lookups.
  357. sub users_with_emails {
  358. my $dbh = _dbh();
  359. my $rows = $dbh->selectall_arrayref(
  360. "SELECT name, contact_email FROM user WHERE contact_email IS NOT NULL AND contact_email != ''",
  361. { Slice => {} }
  362. );
  363. return ref $rows eq 'ARRAY' ? $rows : [];
  364. }
  365. # Ensure the db schema is OK, and give us a handle
  366. sub _dbh {
  367. my $file = 'schema/auth.schema';
  368. my $dbname = "config/auth.db";
  369. return Trog::SQLite::dbh( $file, $dbname );
  370. }
  371. 1;