diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 8ce94767..70bd1f76 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1067,6 +1067,7 @@ def add_cert(ap, cert_path): def add_auth(ap): + ses_db = os.path.join(E.cfg, "sessions.db") ap2 = ap.add_argument_group('IdP / identity provider / user authentication options') 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") 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") @@ -1074,6 +1075,9 @@ def add_auth(ap): 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") 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") 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") + 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)") + ap2.add_argument("--ses-len", metavar="CHARS", type=int, default=20, help="session key length; default is 120 bits ((20//4)*4*6)") + ap2.add_argument("--no-ses", action="store_true", help="disable sessions; use plaintext passwords in cookies") def add_chpw(ap): diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index cbdc5440..0808095c 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -840,8 +840,10 @@ class AuthSrv(object): # fwd-decl self.vfs = VFS(log_func, "", "", AXS(), {}) - self.acct: dict[str, str] = {} - self.iacct: dict[str, str] = {} + self.acct: dict[str, str] = {} # uname->pw + self.iacct: dict[str, str] = {} # pw->uname + self.ases: dict[str, str] = {} # uname->session + self.sesa: dict[str, str] = {} # session->uname self.defpw: dict[str, str] = {} self.grps: dict[str, list[str]] = {} self.re_pwd: Optional[re.Pattern] = None @@ -2181,8 +2183,11 @@ class AuthSrv(object): self.grps = grps self.iacct = {v: k for k, v in acct.items()} + self.load_sessions() + self.re_pwd = None pwds = [re.escape(x) for x in self.iacct.keys()] + pwds.extend(list(self.sesa)) if pwds: if self.ah.on: zs = r"(\[H\] pw:.*|[?&]pw=)([^&]+)" @@ -2257,6 +2262,72 @@ class AuthSrv(object): cur.close() db.close() + def load_sessions(self, quiet=False) -> None: + # mutex me + if self.args.no_ses: + self.ases = {} + self.sesa = {} + return + + import sqlite3 + + ases = {} + blen = (self.args.ses_len // 4) * 4 # 3 bytes in 4 chars + blen = (blen * 3) // 4 # bytes needed for ses_len chars + + db = sqlite3.connect(self.args.ses_db) + cur = db.cursor() + + for uname, sid in cur.execute("select un, si from us"): + if uname in self.acct: + ases[uname] = sid + + n = [] + q = "insert into us values (?,?,?)" + for uname in self.acct: + if uname not in ases: + sid = ub64enc(os.urandom(blen)).decode("utf-8") + cur.execute(q, (uname, sid, int(time.time()))) + ases[uname] = sid + n.append(uname) + + if n: + db.commit() + + cur.close() + db.close() + + self.ases = ases + self.sesa = {v: k for k, v in ases.items()} + if n and not quiet: + t = ", ".join(n[:3]) + if len(n) > 3: + t += "..." + self.log("added %d new sessions (%s)" % (len(n), t)) + + def forget_session(self, broker: Optional["BrokerCli"], uname: str) -> None: + with self.mutex: + self._forget_session(uname) + + if broker: + broker.ask("_reload_sessions").get() + + def _forget_session(self, uname: str) -> None: + if self.args.no_ses: + return + + import sqlite3 + + db = sqlite3.connect(self.args.ses_db) + cur = db.cursor() + cur.execute("delete from us where un = ?", (uname,)) + db.commit() + cur.close() + db.close() + + self.sesa.pop(self.ases.get(uname, ""), "") + self.ases.pop(uname, "") + def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]: if not self.args.chpw: return False, "feature disabled in server config" @@ -2276,7 +2347,7 @@ class AuthSrv(object): if hpw == self.acct[uname]: return False, "that's already your password my dude" - if hpw in self.iacct: + if hpw in self.iacct or hpw in self.sesa: return False, "password is taken" with self.mutex: diff --git a/copyparty/broker_mp.py b/copyparty/broker_mp.py index b09f6ce3..57b2506c 100644 --- a/copyparty/broker_mp.py +++ b/copyparty/broker_mp.py @@ -76,6 +76,10 @@ class BrokerMp(object): for _, proc in enumerate(self.procs): proc.q_pend.put((0, "reload", [])) + def reload_sessions(self) -> None: + for _, proc in enumerate(self.procs): + proc.q_pend.put((0, "reload_sessions", [])) + def collector(self, proc: MProcess) -> None: """receive message from hub in other process""" while True: diff --git a/copyparty/broker_mpw.py b/copyparty/broker_mpw.py index e74c4547..2a961fa3 100644 --- a/copyparty/broker_mpw.py +++ b/copyparty/broker_mpw.py @@ -94,6 +94,10 @@ class MpWorker(BrokerCli): self.asrv.reload() self.logw("mpw.asrv reloaded") + elif dest == "reload_sessions": + with self.asrv.mutex: + self.asrv.load_sessions() + elif dest == "listen": self.httpsrv.listen(args[0], args[1]) diff --git a/copyparty/broker_thr.py b/copyparty/broker_thr.py index e40cd38d..9d3edd5c 100644 --- a/copyparty/broker_thr.py +++ b/copyparty/broker_thr.py @@ -34,6 +34,7 @@ class BrokerThr(BrokerCli): self.iphash = HMaccas(os.path.join(self.args.E.cfg, "iphash"), 8) self.httpsrv = HttpSrv(self, None) self.reload = self.noop + self.reload_sessions = self.noop def shutdown(self) -> None: # self.log("broker", "shutting down") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 6e0335ca..ca1b5a58 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -205,7 +205,8 @@ class HttpCli(object): def unpwd(self, m: Match[str]) -> str: a, b, c = m.groups() - return "%s\033[7m %s \033[27m%s" % (a, self.asrv.iacct[b], c) + uname = self.asrv.iacct.get(b) or self.asrv.sesa.get(b) + return "%s\033[7m %s \033[27m%s" % (a, uname, c) def _check_nonfatal(self, ex: Pebkac, post: bool) -> bool: if post: @@ -504,6 +505,8 @@ class HttpCli(object): zs = base64.b64decode(zb).decode("utf-8") # try "pwd", "x:pwd", "pwd:x" for bauth in [zs] + zs.split(":", 1)[::-1]: + if bauth in self.asrv.sesa: + break hpw = self.asrv.ah.hash(bauth) if self.asrv.iacct.get(hpw): break @@ -565,7 +568,11 @@ class HttpCli(object): self.uname = "*" else: self.pw = uparam.get("pw") or self.headers.get("pw") or bauth or cookie_pw - self.uname = self.asrv.iacct.get(self.asrv.ah.hash(self.pw)) or "*" + self.uname = ( + self.asrv.sesa.get(self.pw) + or self.asrv.iacct.get(self.asrv.ah.hash(self.pw)) + or "*" + ) self.rvol = self.asrv.vfs.aread[self.uname] self.wvol = self.asrv.vfs.awrite[self.uname] @@ -2088,6 +2095,9 @@ class HttpCli(object): if act == "chpw": return self.handle_chpw() + if act == "logout": + return self.handle_logout() + raise Pebkac(422, 'invalid action "{}"'.format(act)) def handle_zip_post(self) -> bool: @@ -2409,7 +2419,8 @@ class HttpCli(object): msg = "new password OK" redir = (self.args.SRS + "?h") if ok else "" - html = self.j2s("msg", h1=msg, h2='ack', redir=redir) + h2 = 'ack' + html = self.j2s("msg", h1=msg, h2=h2, redir=redir) self.reply(html.encode("utf-8")) return True @@ -2422,9 +2433,8 @@ class HttpCli(object): uhash = "" self.parser.drop() - self.out_headerlist = [ - x for x in self.out_headerlist if x[0] != "Set-Cookie" or "cppw" != x[1][:4] - ] + if not pwd: + raise Pebkac(422, "password cannot be blank") dst = self.args.SRS if self.vpath: @@ -2442,9 +2452,27 @@ class HttpCli(object): self.reply(html.encode("utf-8")) return True + def handle_logout(self) -> bool: + assert self.parser + self.parser.drop() + + self.log("logout " + self.uname) + self.asrv.forget_session(self.conn.hsrv.broker, self.uname) + self.get_pwd_cookie("x") + + dst = self.args.SRS + "?h" + h2 = 'ack' + html = self.j2s("msg", h1="ok bye", h2=h2, redir=dst) + self.reply(html.encode("utf-8")) + return True + def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]: - hpwd = self.asrv.ah.hash(pwd) - uname = self.asrv.iacct.get(hpwd) + uname = self.asrv.sesa.get(pwd) + if not uname: + hpwd = self.asrv.ah.hash(pwd) + uname = self.asrv.iacct.get(hpwd) + if uname: + pwd = self.asrv.ases.get(uname) or pwd if uname: msg = "hi " + uname dur = int(60 * 60 * self.args.logout) @@ -2456,8 +2484,9 @@ class HttpCli(object): zb = hashlib.sha512(pwd.encode("utf-8", "replace")).digest() logpwd = "%" + base64.b64encode(zb[:12]).decode("utf-8") - self.log("invalid password: {}".format(logpwd), 3) - self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords") + if pwd != "x": + self.log("invalid password: {}".format(logpwd), 3) + self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords") msg = "naw dude" pwd = "x" # nosec @@ -2469,10 +2498,11 @@ class HttpCli(object): for k in ("cppwd", "cppws") if self.is_https else ("cppwd",): ck = gencookie(k, pwd, self.args.R, False) self.out_headerlist.append(("Set-Cookie", ck)) + self.out_headers.pop("Set-Cookie", None) # drop keepalive else: k = "cppws" if self.is_https else "cppwd" ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly") - self.out_headerlist.append(("Set-Cookie", ck)) + self.out_headers["Set-Cookie"] = ck return dur > 0, msg diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 71bcb34b..98409b83 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -221,6 +221,9 @@ class SvcHub(object): noch.update([x for x in zsl if x]) args.chpw_no = noch + if not self.args.no_ses: + self.setup_session_db() + if args.shr: self.setup_share_db() @@ -369,6 +372,64 @@ class SvcHub(object): self.broker = Broker(self) + def setup_session_db(self) -> None: + if not HAVE_SQLITE3: + self.args.no_ses = True + t = "WARNING: sqlite3 not available; disabling sessions, will use plaintext passwords in cookies" + self.log("root", t, 3) + return + + import sqlite3 + + create = True + db_path = self.args.ses_db + self.log("root", "opening sessions-db %s" % (db_path,)) + for n in range(2): + try: + db = sqlite3.connect(db_path) + cur = db.cursor() + try: + cur.execute("select count(*) from us").fetchone() + create = False + break + except: + pass + except Exception as ex: + if n: + raise + t = "sessions-db corrupt; deleting and recreating: %r" + self.log("root", t % (ex,), 3) + try: + cur.close() # type: ignore + except: + pass + try: + db.close() # type: ignore + except: + pass + os.unlink(db_path) + + sch = [ + r"create table kv (k text, v int)", + r"create table us (un text, si text, t0 int)", + # username, session-id, creation-time + r"create index us_un on us(un)", + r"create index us_si on us(si)", + r"create index us_t0 on us(t0)", + r"insert into kv values ('sver', 1)", + ] + + assert db # type: ignore + assert cur # type: ignore + if create: + for cmd in sch: + cur.execute(cmd) + self.log("root", "created new sessions-db") + db.commit() + + cur.close() + db.close() + def setup_share_db(self) -> None: al = self.args if not HAVE_SQLITE3: @@ -545,7 +606,7 @@ class SvcHub(object): fng = [] t_ff = "transcode audio, create spectrograms, video thumbnails" to_check = [ - (HAVE_SQLITE3, "sqlite", "file and media indexing"), + (HAVE_SQLITE3, "sqlite", "sessions and file/media indexing"), (HAVE_PIL, "pillow", "image thumbnails (plenty fast)"), (HAVE_VIPS, "vips", "image thumbnails (faster, eats more ram)"), (HAVE_WEBP, "pillow-webp", "create thumbnails as webp files"), @@ -945,6 +1006,11 @@ class SvcHub(object): self._reload(rescan_all_vols=rescan_all_vols, up2k=up2k) + def _reload_sessions(self) -> None: + with self.asrv.mutex: + self.asrv.load_sessions(True) + self.broker.reload_sessions() + def stop_thr(self) -> None: while not self.stop_req: with self.stop_cond: diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css index 35bb937a..7035188c 100644 --- a/copyparty/web/browser.css +++ b/copyparty/web/browser.css @@ -604,7 +604,7 @@ html.dy { background: var(--sel-bg); text-shadow: none; } -html,body,tr,th,td,#files,a { +html,body,tr,th,td,#files,a,#blogout { color: inherit; background: none; font-weight: inherit; @@ -687,11 +687,15 @@ html.y #path { #files tbody div a { color: var(--tab-alt); } -a, #files tbody div a:last-child { +a, #blogout, #files tbody div a:last-child { color: var(--a); padding: .2em; text-decoration: none; } +#blogout { + margin: -.2em; +} +#blogout:hover, a:hover { color: var(--a-hil); background: var(--a-h-bg); @@ -935,6 +939,9 @@ html.y #path a:hover { color: var(--srv-3); border-bottom: 1px solid var(--srv-3b); } +#flogout { + display: inline; +} #goh+span { color: var(--bg-u5); padding-left: .5em; diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 96f8d928..930f4c5d 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -7787,7 +7787,7 @@ function apply_perms(res) { ebi('acc_info').innerHTML = '' + srvinf + '' + (acct != '*' ? - '' + (window.is_idp ? '' : L.logout) + acct + '' : + '
' : 'Login'); var o = QSA('#ops>a[data-perm]'); diff --git a/tests/util.py b/tests/util.py index ef4540a6..a824ff37 100644 --- a/tests/util.py +++ b/tests/util.py @@ -120,7 +120,7 @@ class Cfg(Namespace): ex = "chpw daw dav_auth dav_inf dav_mac dav_rt e2d e2ds e2dsa e2t e2ts e2tsr e2v e2vu e2vp early_ban ed emp exp force_js getmod grid gsel hardlink ih ihead magic hardlink_only nid nih no_acode no_athumb no_dav no_db_ip no_del no_dupe no_lifetime no_logues no_mv no_pipe no_poll no_readme no_robots no_sb_md no_sb_lg no_scandir no_tarcmp no_thumb no_vthumb no_zip nrand nw og og_no_head og_s_title q rand smb srch_dbg stats uqe vague_403 vc ver write_uplog xdev xlink xvol zs" ka.update(**{k: False for k in ex.split()}) - ex = "dedup dotpart dotsrch hook_v no_dhash no_fastboot no_fpool no_htp no_rescan no_sendfile no_snap no_voldump re_dhash plain_ip" + ex = "dedup dotpart dotsrch hook_v no_dhash no_fastboot no_fpool no_htp no_rescan no_sendfile no_ses no_snap no_voldump re_dhash plain_ip" ka.update(**{k: True for k in ex.split()}) ex = "ah_cli ah_gen css_browser hist js_browser js_other mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua"