Auth.pm 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. package Trog::Auth;
  2. use strict;
  3. use warnings;
  4. no warnings 'experimental';
  5. use feature qw{signatures state};
  6. use UUID::Tiny ':std';
  7. use Digest::SHA 'sha256';
  8. use Authen::TOTP;
  9. use Imager::QRCode;
  10. use Trog::SQLite;
  11. =head1 Trog::Auth
  12. An SQLite3 authdb.
  13. =head1 Termination Conditions
  14. Throws exceptions in the event the session database cannot be accessed.
  15. =head1 FUNCTIONS
  16. =head2 session2user(STRING sessid) = STRING
  17. Translate a session UUID into a username.
  18. Returns empty string on no active session.
  19. =cut
  20. sub session2user ($sessid) {
  21. my $dbh = _dbh();
  22. my $rows = $dbh->selectall_arrayref("SELECT name FROM sess_user WHERE session=?",{ Slice => {} }, $sessid);
  23. return '' unless ref $rows eq 'ARRAY' && @$rows;
  24. return $rows->[0]->{name};
  25. }
  26. =head2 acls4user(STRING username) = ARRAYREF
  27. Return the list of ACLs belonging to the user.
  28. The function of ACLs are to allow you to access content tagged 'private' which are also tagged with the ACL name.
  29. The 'admin' ACL is the only special one, as it allows for authoring posts, configuring tCMS, adding series (ACLs) and more.
  30. =cut
  31. sub acls4user($username) {
  32. my $dbh = _dbh();
  33. my $records = $dbh->selectall_arrayref("SELECT acl FROM user_acl WHERE username = ?", { Slice => {} }, $username);
  34. return () unless ref $records eq 'ARRAY' && @$records;
  35. my @acls = map { $_->{acl} } @$records;
  36. return \@acls;
  37. }
  38. =head2 totp(user)
  39. Enable TOTP 2fa for the specified user, or if already enabled return the existing info.
  40. Returns a QR code and URI for pasting into authenticator apps.
  41. =cut
  42. sub totp($user, $domain) {
  43. my $totp = _totp();
  44. my $dbh = _dbh();
  45. my $failure = 0;
  46. my $message = "TOTP Secret generated successfully.";
  47. # Make sure we re-generate the same one in case the user forgot.
  48. my $secret;
  49. my $worked = $dbh->selectall_arrayref("SELECT totp_secret FROM user WHERE name = ?", { Slice => {} }, $user);
  50. if ( ref $worked eq 'ARRAY' && @$worked) {
  51. $secret = $worked->[0]{totp_secret};
  52. }
  53. $failure = -1 if $secret;
  54. my $uri = $totp->generate_otp(
  55. user => "$user\@$domain",
  56. issuer => $domain,
  57. period => 60,
  58. $secret ? ( secret => $secret ) : (),
  59. );
  60. if (!$secret) {
  61. $secret = $totp->secret();
  62. $dbh->do("UPDATE user SET totp_secret=? WHERE name=?", undef, $secret, $user) or return (undef, undef, 1, "Failed to store TOTP secret.");
  63. }
  64. # This is subsequently served via authenticated _serve() in TCMS.pm
  65. my $qr = "$user\@$domain.bmp";
  66. if (!-f "totp/$qr") {
  67. my $qrcode = Imager::QRCode->new(
  68. size => 4,
  69. margin => 3,
  70. level => 'L',
  71. casesensitive => 1,
  72. lightcolor => Imager::Color->new(255, 255, 255),
  73. darkcolor => Imager::Color->new(0, 0, 0),
  74. );
  75. my $img = $qrcode->plot($uri);
  76. $img->write(file => "totp/$qr", type => "bmp") or return(undef, undef, 1, "Could not write totp/$qr: ".$img->errstr);
  77. }
  78. return ($uri, $qr, $failure, $message);
  79. }
  80. sub _totp {
  81. state $totp;
  82. if (!$totp) {
  83. my $cfg = Trog::Config->get();
  84. my $global_secret = $cfg->param('totp.secret');
  85. die "Global secret must be set in tCMS configuration totp section!" unless $global_secret;
  86. $totp = Authen::TOTP->new( secret => $global_secret );
  87. die "Cannot instantiate TOTP client!" unless $totp;
  88. }
  89. return $totp;
  90. }
  91. sub clear_totp {
  92. my $dbh = _dbh();
  93. $dbh->do("UPDATE user SET totp_secret=null") or die "Could not clear user TOTP secrets";
  94. #TODO notify users this has happened
  95. }
  96. =head2 mksession(user, pass, token) = STRING
  97. Create a session for the user and waste all other sessions.
  98. Returns a session ID, or blank string in the event the user does not exist or incorrect auth was passed.
  99. =cut
  100. sub mksession ($user, $pass, $token) {
  101. my $dbh = _dbh();
  102. my $totp = _totp();
  103. # Check the password
  104. my $records = $dbh->selectall_arrayref("SELECT salt FROM user WHERE name = ?", { Slice => {} }, $user);
  105. return '' unless ref $records eq 'ARRAY' && @$records;
  106. my $salt = $records->[0]->{salt};
  107. my $hash = sha256($pass.$salt);
  108. my $worked = $dbh->selectall_arrayref("SELECT name, totp_secret FROM user WHERE hash=? AND name = ?", { Slice => {} }, $hash, $user);
  109. return '' unless ref $worked eq 'ARRAY' && @$worked;
  110. my $uid = $worked->[0]{name};
  111. my $secret = $worked->[0]{totp_secret};
  112. # Validate the 2FA Token. If we have no secret, allow login so they can see their QR code, and subsequently re-auth.
  113. if ($secret) {
  114. my $rc = $totp->validate_otp(otp => $token, secret => $secret, tolerance => 1);
  115. return '' unless $rc;
  116. }
  117. # Issue cookie
  118. my $uuid = create_uuid_as_string(UUID_V1, UUID_NS_DNS);
  119. $dbh->do("INSERT OR REPLACE INTO session (id,username) VALUES (?,?)", undef, $uuid, $uid) or return '';
  120. return $uuid;
  121. }
  122. =head2 killsession(user) = BOOL
  123. Delete the provided user's session from the auth db.
  124. =cut
  125. sub killsession ($user) {
  126. my $dbh = _dbh();
  127. $dbh->do("DELETE FROM session WHERE username=?",undef,$user);
  128. return 1;
  129. }
  130. =head2 useradd(user, pass) = BOOL
  131. Adds a user identified by the provided password into the auth DB.
  132. Returns True or False (likely false when user already exists).
  133. =cut
  134. sub useradd ($user, $pass, $acls) {
  135. my $dbh = _dbh();
  136. my $salt = create_uuid();
  137. my $hash = sha256($pass.$salt);
  138. my $res = $dbh->do("INSERT OR REPLACE INTO user (name,salt,hash) VALUES (?,?,?)", undef, $user, $salt, $hash);
  139. return unless $res && ref $acls eq 'ARRAY';
  140. #XXX this is clearly not normalized with an ACL mapping table, will be an issue with large number of users
  141. foreach my $acl (@$acls) {
  142. return unless $dbh->do("INSERT OR REPLACE INTO user_acl (username,acl) VALUES (?,?)", undef, $user, $acl);
  143. }
  144. return 1;
  145. }
  146. # Ensure the db schema is OK, and give us a handle
  147. sub _dbh {
  148. my $file = 'schema/auth.schema';
  149. my $dbname = "config/auth.db";
  150. return Trog::SQLite::dbh($file,$dbname);
  151. }
  152. 1;