3 # Copyright 2021 - 2023 Paul Hänsch
5 # Permission to use, copy, modify, and/or distribute this software for any
6 # purpose with or without fee is hereby granted, provided that the above
7 # copyright notice and this permission notice appear in all copies.
9 # THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
12 # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
15 # IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17 [ -n "$include_users" ] && return 0
20 . "${_EXEC}/cgilite/session.sh"
21 . "${_EXEC}/cgilite/storage.sh"
23 SENDMAIL=${SENDMAIL-sendmail}
25 USER_REGISTRATION="${USER_REGISTRATION-true}"
26 USER_REQUIREEMAIL="${USER_REQUIREEMAIL-true}"
27 USER_ACCOUNTPAGE="${USER_ACCOUNTPAGE}"
29 USER_ACCOUNTEXPIRE="${USER_ACCOUNTEXPIRE:-$((86400 * 730))}"
30 USER_CONFIRMEXPIRE="${USER_CONFIRMEXPIRE:-86400}"
32 MAILFROM="${MAILDOMAIN-noreply@${HTTP_HOST%:*}}"
34 HTTP_HOST="$(HEADER Host)"
36 [ "$HTTPS" ] && SCHEMA=https || SCHEMA=http
39 # UID UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
40 # (pending|active|deleted)
44 USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
45 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
49 USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
50 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
54 # override all functions marked with "TRANSLATION"
55 # sed -n '/TRANSLATION$/,/^}/p;' <cgilite/users.sh
60 user_db="${user_db:-${_DATA}/users.db}"
66 USER_ID='' USER_NAME='' USER_STATUS='' USER_EMAIL='' USER_PWSALT=''
67 USER_PWHASH='' USER_EXPIRE='' USER_DEVICES='' USER_FUTUREUSE=''
70 read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
71 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
72 elif [ "$user" -a -f "$user_db" -a -r "$user_db" ]; then
73 read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
74 USER_EXPIRE USER_DEVICES USER_FUTUREUSE <<-EOF
75 $(grep "^${user} " "${user_db}")
78 if [ "$USER_ID" -a "${USER_EXPIRE:-0}" -gt "$_DATE" ]; then
79 USER_NAME="$(UNSTRING "$USER_NAME")"
80 USER_EMAIL="$(UNSTRING "$USER_EMAIL")"
81 USER_DEVICES="$(UNSTRING "$USER_DEVICES")"
82 unset USER_PWSALT USER_PWHASH
90 # internal function for user update
91 local uid="$1" uname status email pwsalt pwhash expire devices futureuse
92 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
95 for arg in "$@"; do case $arg in
96 uname=*) uname="${arg#*=}";;
97 status=*) status="${arg#*=}";;
98 email=*) email="${arg#*=}";;
99 password=*) pwsalt="$(randomid)"; pwhash="$(user_pwhash "$pwsalt" "${arg#*=}")";;
100 expire=*) expire="${arg#*=}";;
101 devices=*) devices="${arg#*=}";;
104 if LOCK "$user_db"; then
105 while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES \
107 if [ "$UID_" = "$uid" ]; then
108 printf '%s %s %s %s %s %s %i %s %s\n' \
109 "$uid" "$(STRING "${uname-$(UNSTRING "$UNAME")}")" \
110 "${status:-${status-${STATUS}}${status+\\}}" \
111 "${email:-${email-${EMAIL}}${email+\\}}" \
112 "${pwsalt:-${PWSALT}}" "${pwhash:-${PWHASH}}" \
113 "${expire:-$((_DATE + USER_ACCOUNTEXPIRE))}" \
114 "$(STRING "${devices-$(UNSTRING "$DEVICES")}")" \
116 elif [ "$STATUS" = pending -a ! "$EXPIRE" -ge "$_DATE" ]; then
117 # omit expired invitations from output
120 printf '%s %s %s %s %s %s %i %s %s\n' \
121 "$UID_" "$UNAME" "$STATUS" "$EMAIL" "$PWSALT" "$PWHASH" \
122 "$EXPIRE" "$DEVICES" "$FUTUREUSE"
124 done <"$user_db" >"${user_db}.$$"
125 mv -- "${user_db}.$$" "$user_db"
133 local user="${1:-$(timeid)}"
136 if LOCK "$user_db"; then
137 if grep -q "^${user} " "$user_db"; then
141 printf '%s \\ %s \\ \\ \\ %i \\ \\\n' \
142 "$user" "pending" "$(( _DATE + USER_CONFIRMEXPIRE ))" >>"$user_db"
147 if [ $# -eq 0 ]; then
150 elif update_user "$user" "$@"; then
162 if [ ! "$USER_IDMAP" ]; then
164 USER_IDMAP="${USER_IDMAP}${USER_ID} ${USER_NAME}${BR}"
167 if [ "$uid" -a "$USER_IDMAP" != "${USER_IDMAP##*${uid} }" ]; then
168 ret="${USER_IDMAP##*${uid} }"; ret="${ret%%${BR}*}";
171 elif [ "$uid" ]; then
174 printf '%s' "$USER_IDMAP"
180 local name="$(STRING "$1")" ret
181 [ "$USER_IDMAP" ] || user_idmap >/dev/null
183 if [ "${name%\\}" -a "$USER_IDMAP" != "${USER_IDMAP% ${name}${BR}*}" ]; then
184 ret="${USER_IDMAP% ${name}${BR}*}"; ret="${ret##*${BR}}"
193 { [ $# -gt 0 ] && printf %s "$*" || cat; } \
199 /^[a-zA-Z][a-zA-Z0-9 -~]{2,127}$/!d;
205 { [ $# -gt 0 ] && printf %s "$*" || cat; } \
207 # W3C recommended email regex
208 # https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email)
209 /^[a-zA-Z0-9.!#$%&'\''*+\/=?^_`{|}~-]+@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/p;
214 local uname="$(STRING "$1")"
215 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
216 [ -f "$user_db" -a -r "$user_db" ] \
217 && while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE; do
218 [ "$EXPIRE" -gt "$_DATE" -a "$UNAME" = "$uname" ] && return 0
224 local email="$(STRING "$1")"
225 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
226 [ -f "$user_db" -a -r "$user_db" ] \
227 && while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE; do
228 [ "$EXPIRE" -gt "$_DATE" -a "$EMAIL" = "$email" ] && return 0
234 local salt="$1" secret="$2" hash
235 hash="$(printf '%s\n%s\n' "$secret" "$salt" |sha256sum)"
236 printf '%s\n' "${hash%% *}"
239 user_register_email() { # TRANSLATION
240 "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
243 Subject: Your account registration at ${HTTP_HOST%:*}
245 Someone tried to sign up for a user account using this email address.
247 You can activate your account using this link:
249 ${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
251 This registration link will expire after $((USER_CONFIRMEXPIRE / 3600)) hours.
253 If you did not request an account at ${HTTP_HOST%:*}, then someone else
254 probably entered your email address by accident. In this case you shoud
255 simply ignore this message and we will remove your email address from
256 our database within the next day.
258 This is an automatic email. Any direct reply will not be received.
259 Your Account Registration Robot.
264 # reserve account, send registration mail
265 # preliminary uid, expiration, signature
266 local uid="$(timeid)"
267 local uname="$(POST uname |user_checkname)"
268 local email="$(POST email |user_checkemail)"
269 local pwsalt="$(randomid)"
270 local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
272 if [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
273 REDIRECT "${_BASE}${PATH_INFO}#ERROR_REGISTRATION_DISABLED"
276 if [ "$USER_REQUIREEMAIL" = true ]; then
277 if [ ! "email" ]; then
278 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
279 elif user_emailexist "$email"; then
280 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
281 elif new_user "$uid" status=pending email="$email" expire="$((_DATE + USER_CONFIRMEXPIRE))"; then
282 debug "Sending Activation Link:" \
283 "${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
285 REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
287 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
290 elif [ "$USER_REQUIREEMAIL" != true ]; then
291 if [ ! "$uname" ]; then
292 REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_INVALID"
293 elif user_nameexist "$uname"; then
294 REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_EXISTS"
295 elif [ ! "$pw" ]; then
296 REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_EMPTYTOOSHORT"
297 elif [ "$pw" != "$pwconfirm" ]; then
298 REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
299 elif new_user "$uid" uname="$uname" status=active email="$email" password="$pw" expire="$((_DATE + USER_ACCOUNTEXPIRE))"; then
301 SESSION_BIND user_id "$uid"
303 if [ "$USER_ACCOUNTPAGE" ]; then
304 REDIRECT "${USER_ACCOUNTPAGE}"
306 REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
309 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
314 user_invite_email(){ # TRANSLATION
315 "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
318 Subject: You have been invited to ${HTTP_HOST%:*}
320 ${USER_NAME:-Someone} has offered an invitation to this email address.
324 You can create your account using this link:
326 ${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
328 This registration link will expire after $((USER_CONFIRMEXPIRE / 3600)) hours.
330 If you do not know what this is about, then someone else probably
331 entered your email address by accident. In this case you shoud
332 simply ignore this message and we will remove your email address from
333 our database within the next day.
335 This is an automatic email. Any direct reply will not be received.
336 Your Account Registration Robot.
341 local uid="$(timeid)"
342 local email="$(POST email |user_checkemail)"
343 local message="$(POST message)"
345 if [ ! "email" ]; then
346 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
347 elif user_emailexist "$email"; then
348 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
349 elif new_user "$uid" status=pending email="$email" expire="$((_DATE + USER_CONFIRMEXPIRE))"; then
350 debug "Sending Invitation Link:" \
351 "${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
353 REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
355 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
362 local uid="$(POST uid |checkid || printf invalid)"
363 local signature="$(POST signature)"
364 local uname="$(POST uname |user_checkname)"
365 local pwsalt="$(randomid)"
366 local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
370 if [ "$signature" != "$(session_mac "$uid")" ]; then
371 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
372 elif [ ! "$uname" ]; then
373 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_INVALID"
374 elif user_nameexist "$uname"; then
375 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_EXISTS"
376 elif [ ! "$pw" ]; then
377 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_EMPTYTOOSHORT"
378 elif [ "$pw" != "$pwconfirm" ]; then
379 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_MISMATCH"
380 elif [ "$USER_STATUS" != pending -o \! "$USER_EXPIRE" -gt "$_DATE" ]; then
381 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
382 elif update_user "$USER_ID" uname="$uname" status=active password="$pw"; then
384 SESSION_BIND user_id "$USER_ID"
385 if [ "$USER_ACCOUNTPAGE" ]; then
386 REDIRECT "${USER_ACCOUNTPAGE}"
388 REDIRECT "${_BASE}${PATH_INFO}?user_register=confirm#USER_REGISTER_CONFIRM"
391 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
397 # keep logged in - device cookie?
398 # initialize new session!
399 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
400 local uname="$(POST uname |STRING)" pw="$(POST pw)"
402 [ -f "$user_db" -a -r "$user_db" ] \
403 && while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE; do
404 if [ "$UNAME" = "$uname" -o "$EMAIL" = "$uname" ]; then
405 if [ "$STATUS" = active -a "$EXPIRE" -gt "$_DATE" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$pw")" ]; then
407 SESSION_BIND user_id "$UID_"
408 REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_IN"
412 REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_LOGIN"
416 # destroy cookie, destroy session
420 SET_COOKIE 0 user_id="" Path="/${_BASE#/}" SameSite=Strict HttpOnly
421 REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_OUT"
425 # todo: username update, email update / email confirm
426 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
427 # local uname="$(POST uname |STRING)"
428 local uid oldpw pw pwconfirm
431 oldpw="$(POST oldpw)"
432 pw="$(POST pw |grep -xE '.{6}')"
433 pwconfirm="$(POST pwconfirm)"
436 read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE <<-EOF
437 $(grep "^${uid} " "$user_db")
440 if [ "$UID_" = "$USER_ID" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$oldpw")" ]; then
441 if [ "$pw" -a "$pw" = "$pwconfirm" ]; then
442 update_user "${uid}" password="$pw"
443 REDIRECT "${_BASE}${PATH_INFO}#UPDATE_SUCCESS"
445 REDIRECT "${_BASE}${PATH_INFO}#ERROR_PWMISMATCH"
447 elif [ "$UID_" = "$USER_ID" ]; then
448 REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_AUTH_PASSWORD"
450 REDIRECT "${_BASE}${PATH_INFO}#ERROR_NOTLOGGEDIN"
462 read_user "$(SESSION_VAR user_id)"
463 [ "$USER_STATUS" -a "$USER_STATUS" != active ] && eval $UNSET_USER
465 [ "$REQUEST_METHOD" = POST ] && case "$(POST action)" in
466 user_register) user_register ;;
467 user_confirm) user_confirm ;;
468 user_invite) user_invite ;;
469 user_login) user_login ;;
470 user_logout) user_logout ;;
471 user_update) user_update ;;
478 export USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
479 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
483 if [ ! "$USER_ID" ]; then
485 [div #user_update .nouser
486 This page can only be used by registered users
491 [form #user_update method=POST
492 [hidden "uid" "$USER_ID"]
493 [p .username Logged in as $USER_NAME]
494 [input type=password name=oldpw placeholder="Current Passphrase"]
495 [input type=password name=pw placeholder="New Passphrase" pattern=".{6,}"]
496 [input type=password name=pwconfirm placeholder="Confirm New Passphrase" pattern=".{6,}"]
497 [submit "action" "user_update" Update Passphrase]
503 w_user_register_disabled(){ # TRANSLATION
505 [div #user_register .disabled
506 User Registration is disabled.
510 w_user_register_sendmail(){ # TRANSLATION
512 [form #user_register .registeremail method=POST
513 [p We will send an activation mail to your email address.
514 You can continue the signup process when you click on the
515 activation link in this email.]
516 [input type=email name=email placeholder="Email"]
517 [submit "action" "user_register" Sign Up]
521 w_user_register_direct(){ # TRANSLATION
523 [form #user_register .registername method=POST
524 [input name=uname placeholder="Choose Username" tooltip="Your username may contain any character but the @ sign. It must be at least 3 characters long, and it must start with a letter." pattern="^\[\\\\p{L}\]\[\\\\p{L}0-9 -~\]{2,127}$" autocomplete=off]
525 [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
526 [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
527 [submit "action" "user_register" Sign Up]
533 if [ "$(GET user_confirm)" ]; then
535 elif [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
536 w_user_register_disabled
537 elif [ "$USER_REQUIREEMAIL" = true ]; then
538 w_user_register_sendmail
539 elif [ "$USER_REQUIREEMAIL" != true ]; then
540 w_user_register_direct
544 w_user_confirm_proceed(){ # TRANSLATION
546 [form #user_confirm method=POST
547 [input type=hidden name=uid value="${uid}"]
548 [input type=hidden name=signature value="${signature}"]
549 $([ "$EMAIL" != '\' ] && printf \
550 '[input disabled=disabled value="%s" placeholder="Email"]' "$(UNSTRING "$EMAIL" |HTML)"
552 [input name=uname placeholder="Choose Username" tooltip="Your username may contain any character but the @ sign. It must be at least 3 characters long, and it must start with a letter." pattern="^\[\\\\p{L}\]\[\\\\p{L}0-9 -~\]{2,127}$" autocomplete=off]
553 [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
554 [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
555 [submit "action" "user_confirm" Finish Registration]
559 w_user_confirm_expired(){ # TRANSLATION
561 [div #user_confirm .expired
562 [p This activation link is not valid anymore.]
566 w_user_confirm_invalid(){ # TRANSLATION
568 [div #user_confirm .invalid
569 [p This activation link is invalid. Make sure you copied the whole activation link from your email and be careful not to include any line breaks.]
575 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
576 local user_confirm="$(GET user_confirm)"
577 local uid="${user_confirm% *}" signature="${user_confirm#* }"
579 if [ "$signature" = "$(session_mac "$uid")" ]; then
580 read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE <<-EOF
581 $(grep "^${uid} " "$user_db")
583 if [ "$STATUS" = pending -a "$EXPIRE" -gt "$_DATE" ]; then
584 w_user_confirm_proceed
586 w_user_confirm_expired
589 w_user_confirm_invalid
593 w_user_invite_email(){ # TRANSLATION
595 [form #user_invite method=POST
596 [input placeholder="Email Recipient" name=email autocomplete=off]
597 [textarea name="message" placeholder="Message to recipient" . ]
598 [submit "action" "user_invite" Send Invitation]
602 w_user_invite_link(){ # TRANSLATION
604 [div #user_invite .link
605 [p An anonymous user account has been set up. Send the following link to the intended user, so they may claim their account. The link will remain valid for $((USER_CONFIRMEXPIRE / 3600)) hours.]
606 [a href="$(HTML "$invlink")" . $(HTML "$invlink")]
608 [p [a href="#" . Set up another account]]
612 w_user_invite_deny(){ # TRANSLATION
614 [div #user_invite .notallowed
615 Only registered users may send an invitation to another user.
623 if [ "$(GET user_confirm)" ]; then
625 elif [ "$USER_ID" -a "$SENDMAIL" ]; then
627 elif [ "$USER_ID" ]; then
629 new_user "$uid" status=pending expire="$((_DATE + USER_CONFIRMEXPIRE))"
630 invlink="${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
631 debug "New Invitation Link: $invlink"
638 w_user_login_logon(){ # TRANSLATION
640 [form #user_login .login method=POST
641 [input name=uname placeholder="Username or Email" autocomplete=off]
642 [input type=password name=pw placeholder="Passphrase"]
643 [submit "action" "user_login" Login]
647 w_user_login_logoff(){ # TRANSLATION
649 [form #user_login .logout method=POST
650 [p Logged in as [span . $(HTML ${USER_NAME})]]
651 [submit "action" "user_logout" Logout]
657 if [ ! "$USER_ID" ]; then
659 elif [ "$USER_ID" ]; then