make passwords user-changeable; closes #92
This commit is contained in:
		
							parent
							
								
									5a62cb4869
								
							
						
					
					
						commit
						83fb569d61
					
				
							
								
								
									
										24
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								README.md
									
									
									
									
									
								
							| @ -76,6 +76,7 @@ turn almost any device into a file server with resumable uploads/downloads using | ||||
|         * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/)) | ||||
|     * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/)) | ||||
|     * [identity providers](#identity-providers) - replace copyparty passwords with oauth and such | ||||
|     * [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords | ||||
|     * [using the cloud as storage](#using-the-cloud-as-storage) - connecting to an aws s3 bucket and similar | ||||
|     * [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed | ||||
|     * [themes](#themes) | ||||
| @ -1355,6 +1356,29 @@ there is a [docker-compose example](./docs/examples/docker/idp-authelia-traefik) | ||||
| 
 | ||||
| a more complete example of the copyparty configuration options [look like this](./docs/examples/docker/idp/copyparty.conf) | ||||
| 
 | ||||
| but if you just want to let users change their own passwords, then you probably want [user-changeable passwords](#user-changeable-passwords) instead | ||||
| 
 | ||||
| 
 | ||||
| ## user-changeable passwords | ||||
| 
 | ||||
| if permitted, users can change their own passwords  in the control-panel | ||||
| 
 | ||||
| * not compatible with [identity providers](#identity-providers) | ||||
| 
 | ||||
| * must be enabled with `--chpw` because account-sharing is a popular usecase | ||||
| 
 | ||||
|   * if you want to enable the feature but deny password-changing for a specific list of accounts, you can do that with `--chpw-no name1,name2,name3,...` | ||||
| 
 | ||||
| * to perform a password reset, edit the server config and give the user another password there, then do a [config reload](#server-config) or server restart | ||||
| 
 | ||||
| * the custom passwords are kept in a textfile at filesystem-path `--chpw-db`, by default `chpw.json` in the copyparty config folder | ||||
| 
 | ||||
|   * if you run multiple copyparty instances with different users you *almost definitely* want to specify separate DBs for each instance | ||||
| 
 | ||||
|   * if [password hashing](#password-hashing) is enbled, the passwords in the db are also hashed | ||||
| 
 | ||||
|     * ...which means that all user-defined passwords will be forgotten if you change password-hashing settings | ||||
| 
 | ||||
| 
 | ||||
| ## using the cloud as storage | ||||
| 
 | ||||
|  | ||||
| @ -1065,6 +1065,17 @@ def add_auth(ap): | ||||
|     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") | ||||
| 
 | ||||
| 
 | ||||
| def add_chpw(ap): | ||||
|     db_path = os.path.join(E.cfg, "chpw.json") | ||||
|     ap2 = ap.add_argument_group('user-changeable passwords options') | ||||
|     ap2.add_argument("--chpw", action="store_true", help="allow users to change their own passwords") | ||||
|     ap2.add_argument("--chpw-no", metavar="U,U,U", type=u, action="append", help="do not allow password-changes for this comma-separated list of usernames") | ||||
|     ap2.add_argument("--chpw-db", metavar="PATH", type=u, default=db_path, help="where to store the passwords database (if you run multiple copyparty instances, make sure they use different DBs)") | ||||
|     ap2.add_argument("--chpw-len", metavar="N", type=int, default=8, help="minimum password length") | ||||
|     ap2.add_argument("--chpw-v", action="store_true", help="verbose (when loading: list status of each user)") | ||||
|     ap2.add_argument("--chpw-q", action="store_true", help="quiet (when loading: don't print summary)") | ||||
| 
 | ||||
| 
 | ||||
| def add_zeroconf(ap): | ||||
|     ap2 = ap.add_argument_group("Zeroconf options") | ||||
|     ap2.add_argument("-z", action="store_true", help="enable all zeroconf backends (mdns, ssdp)") | ||||
| @ -1473,6 +1484,7 @@ def run_argparse( | ||||
|     add_tls(ap, cert_path) | ||||
|     add_cert(ap, cert_path) | ||||
|     add_auth(ap) | ||||
|     add_chpw(ap) | ||||
|     add_qr(ap, tty) | ||||
|     add_zeroconf(ap) | ||||
|     add_zc_mdns(ap) | ||||
|  | ||||
| @ -4,6 +4,7 @@ from __future__ import print_function, unicode_literals | ||||
| import argparse | ||||
| import base64 | ||||
| import hashlib | ||||
| import json | ||||
| import os | ||||
| import re | ||||
| import stat | ||||
| @ -807,6 +808,7 @@ class AuthSrv(object): | ||||
|         self.vfs = VFS(log_func, "", "", AXS(), {}) | ||||
|         self.acct: dict[str, str] = {} | ||||
|         self.iacct: dict[str, str] = {} | ||||
|         self.defpw: dict[str, str] = {} | ||||
|         self.grps: dict[str, list[str]] = {} | ||||
|         self.re_pwd: Optional[re.Pattern] = None | ||||
| 
 | ||||
| @ -1440,6 +1442,8 @@ class AuthSrv(object): | ||||
|                     raise | ||||
| 
 | ||||
|         self.setup_pwhash(acct) | ||||
|         defpw = acct.copy() | ||||
|         self.setup_chpw(acct) | ||||
| 
 | ||||
|         # case-insensitive; normalize | ||||
|         if WINDOWS: | ||||
| @ -2069,6 +2073,7 @@ class AuthSrv(object): | ||||
| 
 | ||||
|         self.vfs = vfs | ||||
|         self.acct = acct | ||||
|         self.defpw = defpw | ||||
|         self.grps = grps | ||||
|         self.iacct = {v: k for k, v in acct.items()} | ||||
| 
 | ||||
| @ -2089,6 +2094,96 @@ class AuthSrv(object): | ||||
|                 MIMES[ext] = mime | ||||
|             EXTS.update({v: k for k, v in MIMES.items()}) | ||||
| 
 | ||||
|     def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]: | ||||
|         if not self.args.chpw: | ||||
|             return False, "feature disabled in server config" | ||||
| 
 | ||||
|         if uname == "*" or uname not in self.defpw: | ||||
|             return False, "not logged in" | ||||
| 
 | ||||
|         if len(pw) < self.args.chpw_len: | ||||
|             t = "minimum password length: %d characters" | ||||
|             return False, t % (self.args.chpw_len,) | ||||
| 
 | ||||
|         hpw = self.ah.hash(pw) if self.ah.on else pw | ||||
|         if hpw in self.iacct: | ||||
|             return False, "password is taken" | ||||
| 
 | ||||
|         with self.mutex: | ||||
|             ap = self.args.chpw_db | ||||
|             if not bos.path.exists(ap): | ||||
|                 pwdb = {} | ||||
|             else: | ||||
|                 with open(ap, "r", encoding="utf-8") as f: | ||||
|                     pwdb = json.load(f) | ||||
| 
 | ||||
|             pwdb = [x for x in pwdb if x[0] != uname] | ||||
|             pwdb.append((uname, self.defpw[uname], hpw)) | ||||
| 
 | ||||
|             with open(ap, "w", encoding="utf-8") as f: | ||||
|                 json.dump(pwdb, f, separators=(",\n", ": ")) | ||||
| 
 | ||||
|             self.log("reinitializing due to password-change for user [%s]" % (uname,)) | ||||
| 
 | ||||
|             if not broker: | ||||
|                 # only true for tests | ||||
|                 self._reload() | ||||
|                 return True, "new password OK" | ||||
| 
 | ||||
|         broker.ask("_reload_blocking", False, False).get() | ||||
|         return True, "new password OK" | ||||
| 
 | ||||
|     def setup_chpw(self, acct: dict[str, str]) -> None: | ||||
|         ap = self.args.chpw_db | ||||
|         if not self.args.chpw or not bos.path.exists(ap): | ||||
|             return | ||||
| 
 | ||||
|         with open(ap, "r", encoding="utf-8") as f: | ||||
|             pwdb = json.load(f) | ||||
| 
 | ||||
|         u404 = set() | ||||
|         urst = set() | ||||
|         uok = set() | ||||
|         for usr, orig, mod in pwdb: | ||||
|             if usr not in acct: | ||||
|                 u404.add(usr) | ||||
|                 continue | ||||
|             if acct[usr] != orig: | ||||
|                 urst.add(usr) | ||||
|                 continue | ||||
|             uok.add(usr) | ||||
|             acct[usr] = mod | ||||
| 
 | ||||
|         if self.args.chpw_q: | ||||
|             return | ||||
| 
 | ||||
|         for zs in uok: | ||||
|             urst.discard(zs) | ||||
| 
 | ||||
|         if not self.args.chpw_v: | ||||
|             t = "chpw: %d loaded, %d default, %d ignored" | ||||
|             self.log(t % (len(uok), len(urst), len(u404))) | ||||
|             return | ||||
| 
 | ||||
|         msg = "" | ||||
|         if uok: | ||||
|             t = "\033[0mloaded: \033[32m%s" | ||||
|             msg += t % (", ".join(list(uok)),) | ||||
|         if urst: | ||||
|             t = "%s\033[0mdefault: \033[35m%s" | ||||
|             msg += t % ( | ||||
|                 ", " if msg else "", | ||||
|                 ", ".join(list(urst)), | ||||
|             ) | ||||
|         if u404: | ||||
|             t = "%s\033[0mignored: \033[35m%s" | ||||
|             msg += t % ( | ||||
|                 ", " if msg else "", | ||||
|                 ", ".join(list(u404)), | ||||
|             ) | ||||
| 
 | ||||
|         self.log("chpw: " + msg, 6) | ||||
| 
 | ||||
|     def setup_pwhash(self, acct: dict[str, str]) -> None: | ||||
|         self.ah = PWHash(self.args) | ||||
|         if not self.ah.on: | ||||
|  | ||||
| @ -2089,6 +2089,9 @@ class HttpCli(object): | ||||
|         if act == "zip": | ||||
|             return self.handle_zip_post() | ||||
| 
 | ||||
|         if act == "chpw": | ||||
|             return self.handle_chpw() | ||||
| 
 | ||||
|         raise Pebkac(422, 'invalid action "{}"'.format(act)) | ||||
| 
 | ||||
|     def handle_zip_post(self) -> bool: | ||||
| @ -2393,6 +2396,22 @@ class HttpCli(object): | ||||
|         self.reply(b"thank") | ||||
|         return True | ||||
| 
 | ||||
|     def handle_chpw(self) -> bool: | ||||
|         assert self.parser | ||||
|         pwd = self.parser.require("pw", 64) | ||||
|         self.parser.drop() | ||||
| 
 | ||||
|         ok, msg = self.asrv.chpw(self.conn.hsrv.broker, self.uname, pwd) | ||||
|         if ok: | ||||
|             ok, msg = self.get_pwd_cookie(pwd) | ||||
|             if ok: | ||||
|                 msg = "new password OK" | ||||
| 
 | ||||
|         redir = "/?h" if ok else "" | ||||
|         html = self.j2s("msg", h1=msg, h2='<a href="/?h">ack</a>', redir=redir) | ||||
|         self.reply(html.encode("utf-8")) | ||||
|         return True | ||||
| 
 | ||||
|     def handle_login(self) -> bool: | ||||
|         assert self.parser | ||||
|         pwd = self.parser.require("cppwd", 64) | ||||
| @ -2417,12 +2436,12 @@ class HttpCli(object): | ||||
|             dst += "&" if "?" in dst else "?" | ||||
|             dst += "_=1#" + html_escape(uhash, True, True) | ||||
| 
 | ||||
|         msg = self.get_pwd_cookie(pwd) | ||||
|         _, msg = self.get_pwd_cookie(pwd) | ||||
|         html = self.j2s("msg", h1=msg, h2='<a href="' + dst + '">ack</a>', redir=dst) | ||||
|         self.reply(html.encode("utf-8")) | ||||
|         return True | ||||
| 
 | ||||
|     def get_pwd_cookie(self, pwd: str) -> str: | ||||
|     def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]: | ||||
|         hpwd = self.asrv.ah.hash(pwd) | ||||
|         uname = self.asrv.iacct.get(hpwd) | ||||
|         if uname: | ||||
| @ -2454,7 +2473,7 @@ class HttpCli(object): | ||||
|             ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly") | ||||
|             self.out_headerlist.append(("Set-Cookie", ck)) | ||||
| 
 | ||||
|         return msg | ||||
|         return dur > 0, msg | ||||
| 
 | ||||
|     def handle_mkdir(self) -> bool: | ||||
|         assert self.parser | ||||
| @ -3948,6 +3967,7 @@ class HttpCli(object): | ||||
|             k304=self.k304(), | ||||
|             k304vis=self.args.k304 > 0, | ||||
|             ver=S_VERSION if self.args.ver else "", | ||||
|             chpw=self.args.chpw and self.uname != "*", | ||||
|             ahttps="" if self.is_https else "https://" + self.host + self.req, | ||||
|         ) | ||||
|         self.reply(html.encode("utf-8")) | ||||
|  | ||||
| @ -208,6 +208,11 @@ class SvcHub(object): | ||||
|             t = "WARNING: --s-rd-sz (%d) is larger than --iobuf (%d); this may lead to reduced performance" | ||||
|             self.log("root", t % (args.s_rd_sz, args.iobuf), 3) | ||||
| 
 | ||||
|         if args.chpw and args.idp_h_usr: | ||||
|             t = "ERROR: user-changeable passwords is incompatible with IdP/identity-providers; you must disable either --chpw or --idp-h-usr" | ||||
|             self.log("root", t, 1) | ||||
|             raise Exception(t) | ||||
| 
 | ||||
|         bri = "zy"[args.theme % 2 :][:1] | ||||
|         ch = "abcdefghijklmnopqrstuvwx"[int(args.theme / 2)] | ||||
|         args.theme = "{0}{1} {0} {1}".format(ch, bri) | ||||
| @ -815,18 +820,21 @@ class SvcHub(object): | ||||
|         Daemon(self._reload, "reloading") | ||||
|         return "reload initiated" | ||||
| 
 | ||||
|     def _reload(self, rescan_all_vols: bool = True) -> None: | ||||
|     def _reload(self, rescan_all_vols: bool = True, up2k: bool = True) -> None: | ||||
|         with self.up2k.mutex: | ||||
|             if self.reloading != 1: | ||||
|                 return | ||||
|             self.reloading = 2 | ||||
|             self.log("root", "reloading config") | ||||
|             self.asrv.reload() | ||||
|             self.up2k.reload(rescan_all_vols) | ||||
|             if up2k: | ||||
|                 self.up2k.reload(rescan_all_vols) | ||||
|             else: | ||||
|                 self.log("root", "reload done") | ||||
|             self.broker.reload() | ||||
|             self.reloading = 0 | ||||
| 
 | ||||
|     def _reload_blocking(self, rescan_all_vols: bool = True) -> None: | ||||
|     def _reload_blocking(self, rescan_all_vols: bool = True, up2k: bool = True) -> None: | ||||
|         while True: | ||||
|             with self.up2k.mutex: | ||||
|                 if self.reloading < 2: | ||||
| @ -837,7 +845,7 @@ class SvcHub(object): | ||||
|         # try to handle multiple pending IdP reloads at once: | ||||
|         time.sleep(0.2) | ||||
| 
 | ||||
|         self._reload(rescan_all_vols=rescan_all_vols) | ||||
|         self._reload(rescan_all_vols=rescan_all_vols, up2k=up2k) | ||||
| 
 | ||||
|     def stop_thr(self) -> None: | ||||
|         while not self.stop_req: | ||||
|  | ||||
| @ -182,13 +182,15 @@ html.z a.g { | ||||
| 	border-color: #af4; | ||||
| 	box-shadow: 0 .3em 1em #7d0; | ||||
| } | ||||
| #x, | ||||
| input { | ||||
| 	color: #a50; | ||||
| 	background: #fff; | ||||
| 	border: 1px solid #a50; | ||||
| 	border-radius: .5em; | ||||
| 	padding: .5em .7em; | ||||
| 	margin: 0 .5em 0 0; | ||||
| 	border-radius: .3em; | ||||
| 	padding: .3em .6em; | ||||
| 	margin: 0 .3em 0 0; | ||||
| 	font-size: 1em; | ||||
| } | ||||
| input::placeholder { | ||||
| 	font-size: 1.2em; | ||||
| @ -197,6 +199,7 @@ input::placeholder { | ||||
| 	opacity: 0.64; | ||||
| 	color: #930; | ||||
| } | ||||
| #x, | ||||
| html.z input { | ||||
| 	color: #fff; | ||||
| 	background: #626; | ||||
|  | ||||
| @ -92,11 +92,14 @@ | ||||
| 
 | ||||
| 		<h1 id="l">login for more:</h1> | ||||
| 		<div> | ||||
| 			<form method="post" enctype="multipart/form-data" action="{{ r }}/{{ qvpath }}"> | ||||
| 				<input type="hidden" name="act" value="login" /> | ||||
| 				<input type="password" name="cppwd" placeholder=" password" /> | ||||
| 			<form id="lf" method="post" enctype="multipart/form-data" action="{{ r }}/{{ qvpath }}"> | ||||
| 				<input type="hidden" id="la" name="act" value="login" /> | ||||
| 				<input type="password" id="lp" name="cppwd" placeholder=" password" /> | ||||
| 				<input type="hidden" name="uhash" id="uhash" value="x" /> | ||||
| 				<input type="submit" value="Login" /> | ||||
| 				<input type="submit" id="ls" value="Login" /> | ||||
| 				{% if chpw %} | ||||
| 				<a id="x" href="#">change password</a> | ||||
| 				{% endif %} | ||||
| 				{% if ahttps %} | ||||
| 				<a id="w" href="{{ ahttps }}">switch to https</a> | ||||
| 				{% endif %} | ||||
|  | ||||
| @ -27,12 +27,19 @@ var Ls = { | ||||
| 		"v1": "koble til", | ||||
| 		"v2": "bruk denne serveren som en lokal harddisk$N$NADVARSEL: kommer til å vise passordet ditt!", | ||||
| 		"w1": "bytt til https", | ||||
| 		"x1": "bytt passord", | ||||
| 		"ta1": "du må skrive et nytt passord først", | ||||
| 		"ta2": "gjenta for å bekrefte nytt passord:", | ||||
| 		"ta3": "fant en skrivefeil; vennligst prøv igjen", | ||||
| 	}, | ||||
| 	"eng": { | ||||
| 		"d2": "shows the state of all active threads", | ||||
| 		"e2": "reload config files (accounts/volumes/volflags),$Nand rescan all e2ds volumes$N$Nnote: any changes to global settings$Nrequire a full restart to take effect", | ||||
| 		"u2": "time since the last server write$N( upload / rename / ... )$N$N17d = 17 days$N1h23 = 1 hour 23 minutes$N4m56 = 4 minutes 56 seconds", | ||||
| 		"v2": "use this server as a local HDD$N$NWARNING: this will show your password!", | ||||
| 		"ta1": "fill in your new password first", | ||||
| 		"ta2": "repeat to confirm new password:", | ||||
| 		"ta3": "found a typo; please try again", | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| @ -74,3 +81,42 @@ if (o && /[0-9]+$/.exec(o.innerHTML)) | ||||
| 	o.innerHTML = shumantime(o.innerHTML); | ||||
| 
 | ||||
| ebi('uhash').value = '' + location.hash; | ||||
| 
 | ||||
| (function() { | ||||
| 	if (!ebi('x')) | ||||
| 		return; | ||||
| 
 | ||||
| 	var pwi = ebi('lp'); | ||||
| 
 | ||||
| 	function redo(msg) { | ||||
| 		modal.alert(msg, function() { | ||||
| 			pwi.value = ''; | ||||
| 			pwi.focus(); | ||||
| 		}); | ||||
| 	} | ||||
| 	function mok(v) { | ||||
| 		if (v !== pwi.value) | ||||
| 			return redo(d.ta3); | ||||
| 
 | ||||
| 		pwi.setAttribute('name', 'pw'); | ||||
| 		ebi('la').value = 'chpw'; | ||||
| 		ebi('lf').submit(); | ||||
| 	} | ||||
| 	function stars() { | ||||
| 		var m = ebi('modali'); | ||||
| 		function enstars(n) { | ||||
| 			setTimeout(function() { m.value = ''; }, n); | ||||
| 		} | ||||
| 		m.setAttribute('type', 'password'); | ||||
| 		enstars(17); | ||||
| 		enstars(32); | ||||
| 		enstars(69); | ||||
| 	} | ||||
| 	ebi('x').onclick = function (e) { | ||||
| 		ev(e); | ||||
| 		if (!pwi.value) | ||||
| 			return redo(d.ta1); | ||||
| 
 | ||||
| 		modal.prompt(d.ta2, "y", mok, null, stars); | ||||
| 	}; | ||||
| })(); | ||||
|  | ||||
| @ -119,7 +119,7 @@ class Cfg(Namespace): | ||||
|     def __init__(self, a=None, v=None, c=None, **ka0): | ||||
|         ka = {} | ||||
| 
 | ||||
|         ex = "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 never_symlink nid nih no_acode no_athumb no_dav no_dedup 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" | ||||
|         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 never_symlink nid nih no_acode no_athumb no_dav no_dedup 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" | ||||
|         ka.update(**{k: False for k in ex.split()}) | ||||
| 
 | ||||
|         ex = "dotpart dotsrch hook_v no_dhash no_fastboot no_rescan no_sendfile no_snap no_voldump re_dhash plain_ip" | ||||
| @ -137,7 +137,7 @@ class Cfg(Namespace): | ||||
|         ex = "db_act k304 loris re_maxage rproxy rsp_jtr rsp_slp s_wr_slp snap_wri theme themes turbo" | ||||
|         ka.update(**{k: 0 for k in ex.split()}) | ||||
| 
 | ||||
|         ex = "ah_alg bname doctitle df exit favico idp_h_usr html_head lg_sbf log_fk md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i tcolor textfiles unlist vname R RS SR" | ||||
|         ex = "ah_alg bname chpw_db doctitle df exit favico idp_h_usr html_head lg_sbf log_fk md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i tcolor textfiles unlist vname R RS SR" | ||||
|         ka.update(**{k: "" for k in ex.split()}) | ||||
| 
 | ||||
|         ex = "grp on403 on404 xad xar xau xban xbd xbr xbu xiu xm" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 ed
						ed