summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorYorhel <git@yorhel.nl>2022-11-26 08:09:44 +0100
committerYorhel <git@yorhel.nl>2022-11-29 11:24:03 +0100
commit8eee2d30a544dbb43af6b67dfd662fc458fbef01 (patch)
tree648ad55f81fe470ad5a9e9148534796526510b6d /lib
parentee27cc56df80847dca66960064c4622df02fcd76 (diff)
API2: Implement token-based authentication + GET /authinfoHEADmaster
+ update filters and APIs to respect the 'listread' permission.
Diffstat (limited to 'lib')
-rw-r--r--lib/Multi/API.pm2
-rw-r--r--lib/Multi/Maintenance.pm2
-rw-r--r--lib/VNWeb/API.pm61
-rw-r--r--lib/VNWeb/AdvSearch.pm3
-rw-r--r--lib/VNWeb/Auth.pm90
-rw-r--r--lib/VNWeb/DB.pm4
-rw-r--r--lib/VNWeb/Elm.pm1
-rw-r--r--lib/VNWeb/LangPref.pm7
-rw-r--r--lib/VNWeb/ULists/Lib.pm2
-rw-r--r--lib/VNWeb/User/Edit.pm26
10 files changed, 163 insertions, 35 deletions
diff --git a/lib/Multi/API.pm b/lib/Multi/API.pm
index 27b85a0d..738b9999 100644
--- a/lib/Multi/API.pm
+++ b/lib/Multi/API.pm
@@ -296,7 +296,7 @@ sub login {
} elsif(exists $arg->{sessiontoken}) {
return cerr $c, badarg => 'Invalid session token', field => 'sessiontoken' if $arg->{sessiontoken} !~ /^[a-fA-F0-9]{40}$/;
- cpg $c, 'SELECT id, username FROM users WHERE lower(username) = lower($1) AND user_isvalidsession(id, decode($2, \'hex\'), \'api\')',
+ cpg $c, 'SELECT id, username FROM users WHERE lower(username) = lower($1) AND user_validate_session(id, decode($2, \'hex\'), \'api\') IS DISTINCT FROM NULL',
[ $arg->{username}, $arg->{sessiontoken} ], sub {
if($_[0]->nRows == 1) {
$c->{uid} = $_[0]->value(0,0);
diff --git a/lib/Multi/Maintenance.pm b/lib/Multi/Maintenance.pm
index 0cf8f12d..c7b48bd2 100644
--- a/lib/Multi/Maintenance.pm
+++ b/lib/Multi/Maintenance.pm
@@ -87,7 +87,7 @@ my %dailies = (
reviewcache => 'SELECT update_reviews_votes_cache(NULL)',
- cleansessions => q|DELETE FROM sessions WHERE expires < NOW()|,
+ cleansessions => q|DELETE FROM sessions WHERE expires < NOW() AND type <> 'api2'|,
cleannotifications => q|DELETE FROM notifications WHERE read < NOW()-'1 month'::interval|,
cleannotifications2=> q|DELETE FROM notifications WHERE id IN (
SELECT id FROM (SELECT id, row_number() OVER (PARTITION BY uid ORDER BY id DESC) > 500 from notifications) AS x(id,del) WHERE x.del)|,
diff --git a/lib/VNWeb/API.pm b/lib/VNWeb/API.pm
index 9bfb6f31..9a58973e 100644
--- a/lib/VNWeb/API.pm
+++ b/lib/VNWeb/API.pm
@@ -46,7 +46,7 @@ TUWF::options qr{/api/kana.*}, sub {
tuwf->resHeader('Access-Control-Allow-Origin', tuwf->reqHeader('origin'));
tuwf->resHeader('Access-Control-Allow-Credentials', 'true');
tuwf->resHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
- tuwf->resHeader('Access-Control-Allow-Headers', 'Content-Type');
+ tuwf->resHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
tuwf->resHeader('Access-Control-Max-Age', 86400);
};
@@ -57,41 +57,46 @@ TUWF::options qr{/api/kana.*}, sub {
# This throttle state only handles execution time limiting; request limiting
# is done in nginx.
my %throttle; # IP -> SQL time
-my $throttle_start;
sub add_throttle {
my $now = time;
- my $time = $now - $throttle_start;
+ my $time = $now - (tuwf->req->{throttle_start}||$now);
my $norm = norm_ip tuwf->reqIP();
$throttle{$norm} = $now if !$throttle{$norm} || $throttle{$norm} < $now;
$throttle{$norm} += $time * config->{api_throttle}[0];
- $time;
}
sub check_throttle {
- $throttle_start = time;
+ tuwf->req->{throttle_start} = time;
err(429, 'Throttled on query execution time.')
if ($throttle{ norm_ip tuwf->reqIP }||0) >= time + (config->{api_throttle}[0] * config->{api_throttle}[1]);
}
+sub logreq {
+ tuwf->log(sprintf '%4dms %s [%s] "%s" "%s"',
+ tuwf->req->{throttle_start} ? (time - tuwf->req->{throttle_start})*1000 : 0,
+ $_[0],
+ tuwf->reqIP(),
+ tuwf->reqHeader('origin')||'-',
+ tuwf->reqHeader('user-agent')||'');
+}
+
sub err {
my($status, $msg) = @_;
- my $time = add_throttle;
+ add_throttle;
tuwf->resStatus($status);
tuwf->resHeader('Content-type', 'text');
+ tuwf->resHeader('WWW-Authenticate', 'Token') if $status == 401;
print { tuwf->resFd } $msg, "\n";
- tuwf->log(sprintf '%4dms [%s] %d %s "%s"', $time, tuwf->reqIP(), $status, $msg, tuwf->reqHeader('user-agent')||'');
+ logreq "$status $msg";
tuwf->done;
}
sub count_request {
my($rows, $call) = @_;
close tuwf->resFd;
- my $time = add_throttle;
- tuwf->log(sprintf '%4dms %3dr%6db [%s] %s "%s"',
- $time*1000, $rows, length(tuwf->{_TUWF}{Res}{content}),
- tuwf->reqIP(), $call, tuwf->reqHeader('user-agent')||'-'
- );
+ add_throttle;
+ logreq sprintf "%3dr%6db %s", $rows, length(tuwf->{_TUWF}{Res}{content}), $call;
}
@@ -242,7 +247,7 @@ sub api_query {
$req->{count} ? (count => $count) : (),
$req->{compact_filters} ? (compact_filters => $req->{filters}->query_encode) : (),
$req->{normalized_filters} ? (normalized_filters => $req->{filters}->json) : (),
- $req->{time} ? (time => int(1000*(time()-$throttle_start))) : (),
+ $req->{time} ? (time => int(1000*(time() - tuwf->req->{throttle_start}))) : (),
});
cors;
count_request(scalar @$results, sprintf '[%s] {%s %s r%dp%d%s} %s', fmt_fields($req->{fields}),
@@ -419,6 +424,16 @@ api_get '/stats', { map +($_, { uint => 1 }), @STATS }, sub {
};
+api_get '/authinfo', {}, sub {
+ err 401, 'Unauthorized' if !auth;
+ +{
+ id => auth->uid,
+ username => auth->user->{user_name},
+ permissions => [auth->api2Listread ? 'listread' : () ]
+ }
+};
+
+
api_get '/user', {}, sub {
my $q = tuwf->validate(get => q => { type => 'array', scalar => 1, maxlength => 100, values => {} });
err 400, 'Invalid argument' if !$q;
@@ -444,7 +459,7 @@ api_get '/ulist_labels', { labels => { aoh => {
+{ labels => tuwf->dbAlli('
SELECT id, private, label
FROM ulist_labels
- WHERE uid =', \$uid, auth && auth->uid eq $uid ? () : 'AND NOT private',
+ WHERE uid =', \$uid, auth->api2Listread($uid) ? () : 'AND NOT private',
'ORDER BY CASE WHEN id < 10 THEN id ELSE 10 END, label')
}
};
@@ -747,10 +762,11 @@ api_query '/ulist',
sql 'SELECT v.id', $_[0], '
FROM ulist_vns uv
JOIN vnt v ON v.id = uv.vid', $_[1], '
- WHERE NOT v.hidden
- AND NOT uv.c_private
- AND uv.uid =', \$_[3]{user}, '
- AND (', $_[2], ')'
+ WHERE', sql_and
+ 'NOT v.hidden',
+ sql('uv.uid =', \$_[3]{user}),
+ auth->api2Listread($_[3]{user}) ? () : 'NOT uv.c_private',
+ $_[2];
},
fields => {
id => {},
@@ -764,9 +780,12 @@ api_query '/ulist',
labels => {
enrich => sub { sql 'SELECT uv.vid', $_[0], '
FROM ulist_vns uv, unnest(uv.labels) l(id), ulist_labels ul
- WHERE uv.uid =', \$_[3]{user}, 'AND ul.uid =', \$_[3]{user}, 'AND ul.id = l.id
- AND NOT ul.private
- AND uv.vid IN', $_[2] },
+ WHERE', sql_and
+ sql('uv.uid =', \$_[3]{user}),
+ sql('ul.uid =', \$_[3]{user}),
+ 'ul.id = l.id',
+ auth->api2Listread($_[3]{user}) ? () : 'NOT ul.private',
+ sql('uv.vid IN', $_[2]) },
key => 'id', col => 'vid', num => 3,
fields => {
id => { select => 'l.id' },
diff --git a/lib/VNWeb/AdvSearch.pm b/lib/VNWeb/AdvSearch.pm
index 7a3d78a5..a52ff138 100644
--- a/lib/VNWeb/AdvSearch.pm
+++ b/lib/VNWeb/AdvSearch.pm
@@ -325,7 +325,8 @@ f v => 61 => 'has_description', { uint => 1, range => [1,1] }, '=' => sub { 'v."
f v => 62 => 'has_anime', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM vn_anime va WHERE va.id = v.id)' };
f v => 63 => 'has_screenshot', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM vn_screenshots vs WHERE vs.id = v.id)' };
f v => 64 => 'has_review', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM reviews r WHERE r.vid = v.id AND NOT r.c_flagged)' };
-f v => 65 => 'on_list', { uint => 1, range => [1,1] }, '=' => sub { auth ? sql 'v.id IN(SELECT vid FROM ulist_vns WHERE uid =', \auth->uid, ')' : '1=0' };
+f v => 65 => 'on_list', { uint => 1, range => [1,1] },
+ '=' => sub { auth ? sql 'v.id IN(SELECT vid FROM ulist_vns WHERE uid =', \auth->uid, auth->api2Listread ? () : 'AND NOT c_private', ')' : '1=0' };
f v => 66 => 'devstatus', { uint => 1, enum => \%DEVSTATUS }, '=' => sub { 'v.devstatus =', \$_ };
f v => 8 => 'tag', { type => 'any', func => \&_validate_tag }, compact => \&_compact_tag, sql_list => _sql_where_tag('tags_vn_inherit');
diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm
index 061b17ea..3f25cf04 100644
--- a/lib/VNWeb/Auth.pm
+++ b/lib/VNWeb/Auth.pm
@@ -46,7 +46,8 @@ sub auth {
# - If the origin equals the site, use the same Cookie auth as the rest of the site (handy for userscripts)
# - Otherwise, a custom token-based auth, but this hasn't been implemented yet
} elsif(tuwf->reqPath =~ qr{^/api/} && (tuwf->reqHeader('Origin')//'_') ne config->{url}) {
- # TODO
+ # XXX: User prefs and permissions are not loaded in this case - they're not used.
+ $auth->_load_api2(tuwf->reqHeader('authorization'));
} else {
my $cookie = tuwf->reqCookie('auth')||'';
@@ -62,7 +63,7 @@ sub auth {
# have a lot of influence in this)
TUWF::set log_format => sub {
my(undef, $uri, $msg) = @_;
- sprintf "[%s] %s %s: %s\n", scalar localtime(), $uri, tuwf->req && auth ? auth->uid : '-', $msg;
+ sprintf "[%s] %s %s: %s\n", scalar localtime(), $uri, tuwf->req && tuwf->req->{auth} ? auth->uid : '-', $msg;
};
@@ -159,7 +160,7 @@ sub _load_session {
JOIN users_shadow us ON us.id = u.id
JOIN users_prefs up ON up.id = u.id
WHERE u.id = ', \$uid,
- 'AND', sql_func(user_isvalidsession => 'u.id', sql_fromhex($token_db), \'web')
+ 'AND', sql_func(user_validate_session => 'u.id', sql_fromhex($token_db), \'web'), 'IS DISTINCT FROM NULL'
) : {};
# Drop the cookie if it's not valid
@@ -218,9 +219,7 @@ sub resetpass {
# Checks if the password reset token is valid
sub isvalidtoken {
my(undef, $uid, $token) = @_;
- tuwf->dbVali(
- select => sql_func(user_isvalidsession => \$uid, sql_fromhex(sha1_hex lc $token), \'pass')
- );
+ tuwf->dbVali('SELECT', sql_func(user_validate_session => \$uid, sql_fromhex(sha1_hex lc $token), \'pass'), 'IS DISTINCT FROM NULL');
}
@@ -316,4 +315,83 @@ sub audit {
});
}
+
+
+my $api2_alpha = "ybndrfg8ejkmcpqxot1uwisza345h769"; # z-base-32
+
+# Converts from hex to encoded form
+sub _api2_encode {
+ state %l = map +(substr(unpack('B*', chr $_), 3, 8), substr($api2_alpha, $_, 1)), 0..(length($api2_alpha)-1);
+ (unpack('B*', pack('H*', $_[0])) =~ s/(.....)/$l{$1}/erg)
+ =~ s/(....)(.....)(.....)(....)(.....)(.....)(....)/$1-$2-$3-$4-$5-$6-$7/r;
+}
+# Converts from encoded form to hex
+sub _api2_decode {
+ state %l = ('-', '', map +(substr($api2_alpha, $_, 1), substr unpack('B*', chr $_), 3, 8), 0..(length($api2_alpha)-1));
+ unpack 'H*', pack 'B*', $_[0] =~ s{(.)}{$l{$1} // return}erg
+}
+
+# Takes a UID, returns hex value
+sub _api2_gen_token {
+ # Scramble for cosmetic reasons. This bytewise scramble still leaves an obvious pattern, but w/e.
+ unpack 'H*', (pack('N', $_[0] =~ s/^u//r).urandom(16))
+ =~ s/^(.)(.)(.)(.)(..)(....)(....)(....)(..)$/$5$1$6$2$7$3$8$4$9/sr;
+}
+
+# Extract UID from hex-encoded token
+sub _api2_get_uid {
+ 'u'.unpack 'N', pack('H*', $_[0]) =~ s/^..(.)....(.)....(.)....(.)..$/$1$2$3$4/sr;
+}
+
+
+sub _load_api2 {
+ my($self, $header) = @_;
+ return if !$header;
+ return VNWeb::API::err(401, 'Invalid Authorization header format.') if $header !~ /^(?i:Token) +([-$api2_alpha]+)$/;
+ my $token_enc = $1;
+ return VNWeb::API::err(401, 'Invalid token format.') if length($token_enc =~ s/-//rg) != 32 || !length(my $token = _api2_decode $token_enc);
+ my $uid = _api2_get_uid $token;
+ my $user = tuwf->dbRowi(
+ 'SELECT ', sql_user(), ', x.listread
+ FROM users u,', sql_func(user_validate_session => \$uid, sql_fromhex($token), \'api2'), 'x
+ WHERE u.id = ', \$uid, 'AND x.uid = u.id'
+ );
+ return VNWeb::API::err(401, 'Invalid token.') if !$user->{user_id};
+ $self->{token} = $token;
+ $self->{user} = $user;
+ $self->{api2} = 1;
+}
+
+sub api2_tokens {
+ my($self, $uid) = @_;
+ return [] if !$self;
+ my $r = tuwf->dbAlli("
+ SELECT coalesce(notes, '') AS notes, listread, added::date,", sql_tohex('token'), "AS token
+ , (CASE WHEN expires = added THEN '' ELSE expires::date::text END) AS lastused
+ FROM", sql_func(user_api2_tokens => \$uid, \$self->uid, sql_fromhex($self->{token})), '
+ ORDER BY added');
+ $_->{token} = _api2_encode($_->{token}) for @$r;
+ $r;
+}
+
+sub api2_set_token {
+ my($self, $uid, %o) = @_;
+ return if !auth;
+ my $token = $o{token} ? _api2_decode($o{token}) : _api2_gen_token($uid);
+ tuwf->dbExeci(select => sql_func user_api2_set_token => \$uid, \$self->uid, sql_fromhex($self->{token}),
+ sql_fromhex($token), \$o{notes}, \($o{listread}//0));
+ _api2_encode($token);
+}
+
+sub api2_del_token {
+ my($self, $uid, $token) = @_;
+ return if !$self;
+ tuwf->dbExeci(select => sql_func user_api2_del_token => \$uid, \$self->uid, sql_fromhex($self->{token}), sql_fromhex(_api2_decode($token)));
+}
+
+
+# API-specific permission checks
+# (Always return true for cookie-based auth)
+sub api2Listread { $_[0]{user}{user_id} && (!$_[1] || $_[0]{user}{user_id} eq $_[1]) && (!$_[0]{api2} || $_[0]{user}{listread}) }
+
1;
diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm
index 308196b1..88ad89af 100644
--- a/lib/VNWeb/DB.pm
+++ b/lib/VNWeb/DB.pm
@@ -27,8 +27,8 @@ our @EXPORT = qw/
sub interp_warn {
my @r = sql_interp @_;
# 0 and 1 aren't interesting, "SELECT 1" is a common pattern and so is "x > 0".
- # '{7}' is commonly used in ulist filtering and r18 is a valid database column.
- carp "Possible SQL injection in '$r[0]'" if tuwf->debug && ($r[0] =~ s/(?:r18|\{7\})//rg) =~ /[2-9]/;
+ # '{7}' is commonly used in ulist filtering and r18/api2 are a valid database identifiers.
+ carp "Possible SQL injection in '$r[0]'" if tuwf->debug && ($r[0] =~ s/(?:r18|\{7\}|api2)//rg) =~ /[2-9]/;
return @r;
}
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
index 5981138d..8a2334c7 100644
--- a/lib/VNWeb/Elm.pm
+++ b/lib/VNWeb/Elm.pm
@@ -55,6 +55,7 @@ our %apis = (
MailChange => [], # A confirmation mail has been sent to change a user's email address
ImgFormat => [], # Unrecognized image format
LabelId => [{uint => 1}], # Label created
+ Api2Token => [{},{}], # Generated API2 token
DupNames => [ { aoh => { # Duplicate names/aliases (for tags & traits)
id => { vndbid => ['i','g'] },
name => {},
diff --git a/lib/VNWeb/LangPref.pm b/lib/VNWeb/LangPref.pm
index 38e9475f..153a406b 100644
--- a/lib/VNWeb/LangPref.pm
+++ b/lib/VNWeb/LangPref.pm
@@ -8,6 +8,8 @@ use VNWeb::DB;
use VNWeb::Validation;
use Exporter 'import';
+return 1 if $main::ONLYAPI;
+
our @EXPORT = qw/
langpref_parse
langpref_fmt
@@ -76,8 +78,9 @@ my $CURRENT_SESSION = $DEFAULT_SESSION;
sub pref {
- my $titles = langpref_parse(auth->pref('title_langs')) // $DEFAULT_TITLE_LANGS;
- my $alttitles = langpref_parse(auth->pref('alttitle_langs')) // $DEFAULT_ALTTITLE_LANGS;
+ my $inapi = tuwf->reqPath() =~ qr{^/api/} ? 1 : undef;
+ my $titles = (!$inapi && langpref_parse(auth->pref('title_langs'))) || $DEFAULT_TITLE_LANGS;
+ my $alttitles = (!$inapi && langpref_parse(auth->pref('alttitle_langs'))) || $DEFAULT_ALTTITLE_LANGS;
# Make sure that we always have a fallback to the original title.
push @$titles, @$DEFAULT_TITLE_LANGS if !@$titles || defined $titles->[$#$titles]{lang};
tuwf->req->{langpref} //= [ $titles, $alttitles, langpref_fmt($titles).langpref_fmt($alttitles) ];
diff --git a/lib/VNWeb/ULists/Lib.pm b/lib/VNWeb/ULists/Lib.pm
index c44c8aa3..5f9ad589 100644
--- a/lib/VNWeb/ULists/Lib.pm
+++ b/lib/VNWeb/ULists/Lib.pm
@@ -8,7 +8,7 @@ our @EXPORT = qw/ulists_own enrich_ulists_widget ulists_widget_ ulists_widget_fu
# Do we have "ownership" access to this users' list (i.e. can we edit and see private stuff)?
sub ulists_own {
- auth->permUsermod || (auth && auth->uid eq shift)
+ auth->permUsermod || auth->api2Listread(shift)
}
diff --git a/lib/VNWeb/User/Edit.pm b/lib/VNWeb/User/Edit.pm
index e52758ff..4a7fa74b 100644
--- a/lib/VNWeb/User/Edit.pm
+++ b/lib/VNWeb/User/Edit.pm
@@ -76,6 +76,15 @@ my $FORM = {
group => { required => 0 },
} },
+ api2 => { maxlength => 64, aoh => {
+ token => {},
+ added => {},
+ lastused => { required => 0, default => '' },
+ notes => { required => 0, default => '', maxlength => 1000 },
+ listread => { anybool => 1 },
+ delete => { anybool => 1 },
+ } },
+
# Supporter options
nodistract_noads => { anybool => 1 },
nodistract_nofancy => { anybool => 1 },
@@ -128,6 +137,8 @@ TUWF::get qr{/$RE{uid}/edit}, sub {
$u->{prefs}{traits} = tuwf->dbAlli('SELECT u.tid, t.name, g.name AS "group" FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.group WHERE u.id =', \$u->{id}, 'ORDER BY g.order, t.name');
$u->{prefs}{tagprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.childs, t.name FROM users_prefs_tags u JOIN tags t ON t.id = u.tid WHERE u.id =', \$u->{id}, 'ORDER BY t.name');
$u->{prefs}{traitprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.childs, t.name, g.name as "group" FROM users_prefs_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.group WHERE u.id =', \$u->{id}, 'ORDER BY g.order, t.name');
+ $u->{prefs}{api2} = auth->api2_tokens($u->{id});
+ $_->{delete} = 0 for $u->{prefs}{api2}->@*;
}
$u->{admin} = auth->permDbmod || auth->permUsermod || auth->permTagmod || auth->permBoardmod ?
@@ -183,6 +194,16 @@ elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
tuwf->dbExeci('DELETE FROM users_prefs_traits WHERE id =', \$data->{id});
tuwf->dbExeci('INSERT INTO users_prefs_traits', { id => $data->{id}, tid => $_->{tid}, spoil => $_->{spoil}, childs => $_->{childs} }) for $p->{traitprefs}->@*;
+
+ my %tokens = map +($_->{token},$_), $p->{api2}->@*;
+ for (auth->api2_tokens($data->{id})->@*) {
+ my $t = $tokens{$_->{token}} // next;
+ if($t->{delete}) {
+ auth->api2_del_token($data->{id}, $t->{token});
+ } elsif($t->{notes} ne $_->{notes} || !$t->{listread} ne !$_->{listread}) {
+ auth->api2_set_token($data->{id}, %$t);
+ }
+ }
}
if(auth->permUsermod) {
@@ -278,4 +299,9 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
};
};
+
+elm_api UserApi2New => undef, { id => { vndbid => 'u' }}, sub {
+ elm_Api2Token auth->api2_set_token($_[0]{id}), strftime '%Y-%m-%d', localtime;
+};
+
1;