Skip to content

Commit b540517

Browse files
committed
add login sessions
1 parent 6eee601 commit b540517

File tree

10 files changed

+206
-19
lines changed

10 files changed

+206
-19
lines changed

copyparty/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,13 +1067,17 @@ def add_cert(ap, cert_path):
10671067

10681068

10691069
def add_auth(ap):
1070+
ses_db = os.path.join(E.cfg, "sessions.db")
10701071
ap2 = ap.add_argument_group('IdP / identity provider / user authentication options')
10711072
ap2.add_argument("--idp-h-usr", metavar="HN", type=u, default="", help="bypass the copyparty authentication checks and assume the request-header \033[33mHN\033[0m contains the username of the requesting user (for use with authentik/oauth/...)\n\033[1;31mWARNING:\033[0m if you enable this, make sure clients are unable to specify this header themselves; must be washed away and replaced by a reverse-proxy")
10721073
ap2.add_argument("--idp-h-grp", metavar="HN", type=u, default="", help="assume the request-header \033[33mHN\033[0m contains the groupname of the requesting user; can be referenced in config files for group-based access control")
10731074
ap2.add_argument("--idp-h-key", metavar="HN", type=u, default="", help="optional but recommended safeguard; your reverse-proxy will insert a secret header named \033[33mHN\033[0m into all requests, and the other IdP headers will be ignored if this header is not present")
10741075
ap2.add_argument("--idp-gsep", metavar="RE", type=u, default="|:;+,", help="if there are multiple groups in \033[33m--idp-h-grp\033[0m, they are separated by one of the characters in \033[33mRE\033[0m")
10751076
ap2.add_argument("--no-bauth", action="store_true", help="disable basic-authentication support; do not accept passwords from the 'Authenticate' header at all. NOTE: This breaks support for the android app")
10761077
ap2.add_argument("--bauth-last", action="store_true", help="keeps basic-authentication enabled, but only as a last-resort; if a cookie is also provided then the cookie wins")
1078+
ap2.add_argument("--ses-db", metavar="PATH", type=u, default=ses_db, help="where to store the sessions database (if you run multiple copyparty instances, make sure they use different DBs)")
1079+
ap2.add_argument("--ses-len", metavar="CHARS", type=int, default=20, help="session key length; default is 120 bits ((20//4)*4*6)")
1080+
ap2.add_argument("--no-ses", action="store_true", help="disable sessions; use plaintext passwords in cookies")
10771081

10781082

10791083
def add_chpw(ap):

copyparty/authsrv.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -840,8 +840,10 @@ def __init__(
840840

841841
# fwd-decl
842842
self.vfs = VFS(log_func, "", "", AXS(), {})
843-
self.acct: dict[str, str] = {}
844-
self.iacct: dict[str, str] = {}
843+
self.acct: dict[str, str] = {} # uname->pw
844+
self.iacct: dict[str, str] = {} # pw->uname
845+
self.ases: dict[str, str] = {} # uname->session
846+
self.sesa: dict[str, str] = {} # session->uname
845847
self.defpw: dict[str, str] = {}
846848
self.grps: dict[str, list[str]] = {}
847849
self.re_pwd: Optional[re.Pattern] = None
@@ -2181,8 +2183,11 @@ def _reload(self, verbosity: int = 9) -> None:
21812183
self.grps = grps
21822184
self.iacct = {v: k for k, v in acct.items()}
21832185

2186+
self.load_sessions()
2187+
21842188
self.re_pwd = None
21852189
pwds = [re.escape(x) for x in self.iacct.keys()]
2190+
pwds.extend(list(self.sesa))
21862191
if pwds:
21872192
if self.ah.on:
21882193
zs = r"(\[H\] pw:.*|[?&]pw=)([^&]+)"
@@ -2257,6 +2262,72 @@ def _reload(self, verbosity: int = 9) -> None:
22572262
cur.close()
22582263
db.close()
22592264

2265+
def load_sessions(self, quiet=False) -> None:
2266+
# mutex me
2267+
if self.args.no_ses:
2268+
self.ases = {}
2269+
self.sesa = {}
2270+
return
2271+
2272+
import sqlite3
2273+
2274+
ases = {}
2275+
blen = (self.args.ses_len // 4) * 4 # 3 bytes in 4 chars
2276+
blen = (blen * 3) // 4 # bytes needed for ses_len chars
2277+
2278+
db = sqlite3.connect(self.args.ses_db)
2279+
cur = db.cursor()
2280+
2281+
for uname, sid in cur.execute("select un, si from us"):
2282+
if uname in self.acct:
2283+
ases[uname] = sid
2284+
2285+
n = []
2286+
q = "insert into us values (?,?,?)"
2287+
for uname in self.acct:
2288+
if uname not in ases:
2289+
sid = ub64enc(os.urandom(blen)).decode("utf-8")
2290+
cur.execute(q, (uname, sid, int(time.time())))
2291+
ases[uname] = sid
2292+
n.append(uname)
2293+
2294+
if n:
2295+
db.commit()
2296+
2297+
cur.close()
2298+
db.close()
2299+
2300+
self.ases = ases
2301+
self.sesa = {v: k for k, v in ases.items()}
2302+
if n and not quiet:
2303+
t = ", ".join(n[:3])
2304+
if len(n) > 3:
2305+
t += "..."
2306+
self.log("added %d new sessions (%s)" % (len(n), t))
2307+
2308+
def forget_session(self, broker: Optional["BrokerCli"], uname: str) -> None:
2309+
with self.mutex:
2310+
self._forget_session(uname)
2311+
2312+
if broker:
2313+
broker.ask("_reload_sessions").get()
2314+
2315+
def _forget_session(self, uname: str) -> None:
2316+
if self.args.no_ses:
2317+
return
2318+
2319+
import sqlite3
2320+
2321+
db = sqlite3.connect(self.args.ses_db)
2322+
cur = db.cursor()
2323+
cur.execute("delete from us where un = ?", (uname,))
2324+
db.commit()
2325+
cur.close()
2326+
db.close()
2327+
2328+
self.sesa.pop(self.ases.get(uname, ""), "")
2329+
self.ases.pop(uname, "")
2330+
22602331
def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]:
22612332
if not self.args.chpw:
22622333
return False, "feature disabled in server config"
@@ -2276,7 +2347,7 @@ def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]:
22762347
if hpw == self.acct[uname]:
22772348
return False, "that's already your password my dude"
22782349

2279-
if hpw in self.iacct:
2350+
if hpw in self.iacct or hpw in self.sesa:
22802351
return False, "password is taken"
22812352

22822353
with self.mutex:

copyparty/broker_mp.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ def reload(self) -> None:
7676
for _, proc in enumerate(self.procs):
7777
proc.q_pend.put((0, "reload", []))
7878

79+
def reload_sessions(self) -> None:
80+
for _, proc in enumerate(self.procs):
81+
proc.q_pend.put((0, "reload_sessions", []))
82+
7983
def collector(self, proc: MProcess) -> None:
8084
"""receive message from hub in other process"""
8185
while True:

copyparty/broker_mpw.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ def main(self) -> None:
9494
self.asrv.reload()
9595
self.logw("mpw.asrv reloaded")
9696

97+
elif dest == "reload_sessions":
98+
with self.asrv.mutex:
99+
self.asrv.load_sessions()
100+
97101
elif dest == "listen":
98102
self.httpsrv.listen(args[0], args[1])
99103

copyparty/broker_thr.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __init__(self, hub: "SvcHub") -> None:
3434
self.iphash = HMaccas(os.path.join(self.args.E.cfg, "iphash"), 8)
3535
self.httpsrv = HttpSrv(self, None)
3636
self.reload = self.noop
37+
self.reload_sessions = self.noop
3738

3839
def shutdown(self) -> None:
3940
# self.log("broker", "shutting down")

copyparty/httpcli.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ def log(self, msg: str, c: Union[int, str] = 0) -> None:
205205

206206
def unpwd(self, m: Match[str]) -> str:
207207
a, b, c = m.groups()
208-
return "%s\033[7m %s \033[27m%s" % (a, self.asrv.iacct[b], c)
208+
uname = self.asrv.iacct.get(b) or self.asrv.sesa.get(b)
209+
return "%s\033[7m %s \033[27m%s" % (a, uname, c)
209210

210211
def _check_nonfatal(self, ex: Pebkac, post: bool) -> bool:
211212
if post:
@@ -504,6 +505,8 @@ def run(self) -> bool:
504505
zs = base64.b64decode(zb).decode("utf-8")
505506
# try "pwd", "x:pwd", "pwd:x"
506507
for bauth in [zs] + zs.split(":", 1)[::-1]:
508+
if bauth in self.asrv.sesa:
509+
break
507510
hpw = self.asrv.ah.hash(bauth)
508511
if self.asrv.iacct.get(hpw):
509512
break
@@ -565,7 +568,11 @@ def run(self) -> bool:
565568
self.uname = "*"
566569
else:
567570
self.pw = uparam.get("pw") or self.headers.get("pw") or bauth or cookie_pw
568-
self.uname = self.asrv.iacct.get(self.asrv.ah.hash(self.pw)) or "*"
571+
self.uname = (
572+
self.asrv.sesa.get(self.pw)
573+
or self.asrv.iacct.get(self.asrv.ah.hash(self.pw))
574+
or "*"
575+
)
569576

570577
self.rvol = self.asrv.vfs.aread[self.uname]
571578
self.wvol = self.asrv.vfs.awrite[self.uname]
@@ -2088,6 +2095,9 @@ def handle_post_multipart(self) -> bool:
20882095
if act == "chpw":
20892096
return self.handle_chpw()
20902097

2098+
if act == "logout":
2099+
return self.handle_logout()
2100+
20912101
raise Pebkac(422, 'invalid action "{}"'.format(act))
20922102

20932103
def handle_zip_post(self) -> bool:
@@ -2409,7 +2419,8 @@ def handle_chpw(self) -> bool:
24092419
msg = "new password OK"
24102420

24112421
redir = (self.args.SRS + "?h") if ok else ""
2412-
html = self.j2s("msg", h1=msg, h2='<a href="/?h">ack</a>', redir=redir)
2422+
h2 = '<a href="' + self.args.SRS + '?h">ack</a>'
2423+
html = self.j2s("msg", h1=msg, h2=h2, redir=redir)
24132424
self.reply(html.encode("utf-8"))
24142425
return True
24152426

@@ -2422,9 +2433,8 @@ def handle_login(self) -> bool:
24222433
uhash = ""
24232434
self.parser.drop()
24242435

2425-
self.out_headerlist = [
2426-
x for x in self.out_headerlist if x[0] != "Set-Cookie" or "cppw" != x[1][:4]
2427-
]
2436+
if not pwd:
2437+
raise Pebkac(422, "password cannot be blank")
24282438

24292439
dst = self.args.SRS
24302440
if self.vpath:
@@ -2442,9 +2452,27 @@ def handle_login(self) -> bool:
24422452
self.reply(html.encode("utf-8"))
24432453
return True
24442454

2455+
def handle_logout(self) -> bool:
2456+
assert self.parser
2457+
self.parser.drop()
2458+
2459+
self.log("logout " + self.uname)
2460+
self.asrv.forget_session(self.conn.hsrv.broker, self.uname)
2461+
self.get_pwd_cookie("x")
2462+
2463+
dst = self.args.SRS + "?h"
2464+
h2 = '<a href="' + dst + '">ack</a>'
2465+
html = self.j2s("msg", h1="ok bye", h2=h2, redir=dst)
2466+
self.reply(html.encode("utf-8"))
2467+
return True
2468+
24452469
def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]:
2446-
hpwd = self.asrv.ah.hash(pwd)
2447-
uname = self.asrv.iacct.get(hpwd)
2470+
uname = self.asrv.sesa.get(pwd)
2471+
if not uname:
2472+
hpwd = self.asrv.ah.hash(pwd)
2473+
uname = self.asrv.iacct.get(hpwd)
2474+
if uname:
2475+
pwd = self.asrv.ases.get(uname) or pwd
24482476
if uname:
24492477
msg = "hi " + uname
24502478
dur = int(60 * 60 * self.args.logout)
@@ -2456,8 +2484,9 @@ def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]:
24562484
zb = hashlib.sha512(pwd.encode("utf-8", "replace")).digest()
24572485
logpwd = "%" + base64.b64encode(zb[:12]).decode("utf-8")
24582486

2459-
self.log("invalid password: {}".format(logpwd), 3)
2460-
self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords")
2487+
if pwd != "x":
2488+
self.log("invalid password: {}".format(logpwd), 3)
2489+
self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords")
24612490

24622491
msg = "naw dude"
24632492
pwd = "x" # nosec
@@ -2469,10 +2498,11 @@ def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]:
24692498
for k in ("cppwd", "cppws") if self.is_https else ("cppwd",):
24702499
ck = gencookie(k, pwd, self.args.R, False)
24712500
self.out_headerlist.append(("Set-Cookie", ck))
2501+
self.out_headers.pop("Set-Cookie", None) # drop keepalive
24722502
else:
24732503
k = "cppws" if self.is_https else "cppwd"
24742504
ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly")
2475-
self.out_headerlist.append(("Set-Cookie", ck))
2505+
self.out_headers["Set-Cookie"] = ck
24762506

24772507
return dur > 0, msg
24782508

copyparty/svchub.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ def __init__(
221221
noch.update([x for x in zsl if x])
222222
args.chpw_no = noch
223223

224+
if not self.args.no_ses:
225+
self.setup_session_db()
226+
224227
if args.shr:
225228
self.setup_share_db()
226229

@@ -369,6 +372,64 @@ def __init__(
369372

370373
self.broker = Broker(self)
371374

375+
def setup_session_db(self) -> None:
376+
if not HAVE_SQLITE3:
377+
self.args.no_ses = True
378+
t = "WARNING: sqlite3 not available; disabling sessions, will use plaintext passwords in cookies"
379+
self.log("root", t, 3)
380+
return
381+
382+
import sqlite3
383+
384+
create = True
385+
db_path = self.args.ses_db
386+
self.log("root", "opening sessions-db %s" % (db_path,))
387+
for n in range(2):
388+
try:
389+
db = sqlite3.connect(db_path)
390+
cur = db.cursor()
391+
try:
392+
cur.execute("select count(*) from us").fetchone()
393+
create = False
394+
break
395+
except:
396+
pass
397+
except Exception as ex:
398+
if n:
399+
raise
400+
t = "sessions-db corrupt; deleting and recreating: %r"
401+
self.log("root", t % (ex,), 3)
402+
try:
403+
cur.close() # type: ignore
404+
except:
405+
pass
406+
try:
407+
db.close() # type: ignore
408+
except:
409+
pass
410+
os.unlink(db_path)
411+
412+
sch = [
413+
r"create table kv (k text, v int)",
414+
r"create table us (un text, si text, t0 int)",
415+
# username, session-id, creation-time
416+
r"create index us_un on us(un)",
417+
r"create index us_si on us(si)",
418+
r"create index us_t0 on us(t0)",
419+
r"insert into kv values ('sver', 1)",
420+
]
421+
422+
assert db # type: ignore
423+
assert cur # type: ignore
424+
if create:
425+
for cmd in sch:
426+
cur.execute(cmd)
427+
self.log("root", "created new sessions-db")
428+
db.commit()
429+
430+
cur.close()
431+
db.close()
432+
372433
def setup_share_db(self) -> None:
373434
al = self.args
374435
if not HAVE_SQLITE3:
@@ -545,7 +606,7 @@ def _feature_test(self) -> None:
545606
fng = []
546607
t_ff = "transcode audio, create spectrograms, video thumbnails"
547608
to_check = [
548-
(HAVE_SQLITE3, "sqlite", "file and media indexing"),
609+
(HAVE_SQLITE3, "sqlite", "sessions and file/media indexing"),
549610
(HAVE_PIL, "pillow", "image thumbnails (plenty fast)"),
550611
(HAVE_VIPS, "vips", "image thumbnails (faster, eats more ram)"),
551612
(HAVE_WEBP, "pillow-webp", "create thumbnails as webp files"),
@@ -945,6 +1006,11 @@ def _reload_blocking(self, rescan_all_vols: bool = True, up2k: bool = True) -> N
9451006

9461007
self._reload(rescan_all_vols=rescan_all_vols, up2k=up2k)
9471008

1009+
def _reload_sessions(self) -> None:
1010+
with self.asrv.mutex:
1011+
self.asrv.load_sessions(True)
1012+
self.broker.reload_sessions()
1013+
9481014
def stop_thr(self) -> None:
9491015
while not self.stop_req:
9501016
with self.stop_cond:

0 commit comments

Comments
 (0)