]> git.plutz.net Git - cgilite/blob - users.sh
44a5e136c9b9ba1189d55880009e799c9468668b
[cgilite] / users.sh
1 #!/bin/sh
2
3 [ -n "$include_users" ] && return 0
4 include_users="$0"
5
6 . "${_EXEC}/cgilite/session.sh"
7 . "${_EXEC}/cgilite/storage.sh"
8
9 SENDMAIL=${SENDMAIL-sendmail}
10
11 USER_REGISTRATION="${USER_REGISTRATION-true}"
12 USER_REQUIREEMAIL="${USER_REQUIREEMAIL-true}"
13 USER_ACCOUNTPAGE="${USER_ACCOUNTPAGE}"
14
15 MAILFROM="${MAILDOMAIN-noreply@${HTTP_HOST%:*}}"
16
17 HTTP_HOST="$(HEADER Host)"
18
19 # == FILE FORMAT ==
20 # UID   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
21 #               (pending|active|deleted)
22
23 # == GLOBALS ==
24 UNSET_USER='unset \
25   USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
26   USER_EXPIRE USER_DEVICES USER_FUTUREUSE
27 '
28
29 LOCAL_USER='local \
30   USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
31   USER_EXPIRE USER_DEVICES USER_FUTUREUSE
32 '
33
34 unset USER_IDMAP
35 eval "$UNSET_USER"
36
37 user_db="${user_db:-${_DATA}/users.db}"
38
39 read_user() {
40   local user="$1"
41
42   # Global exports
43   USER_ID='' USER_NAME='' USER_STATUS='' USER_EMAIL='' USER_PWSALT=''
44   USER_PWHASH='' USER_EXPIRE='' USER_DEVICES='' USER_FUTUREUSE=''
45
46   if [ $# -eq 0 ]; then
47     read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
48             USER_EXPIRE USER_DEVICES USER_FUTUREUSE
49   elif [ "$user" -a -f "$user_db" -a -r "$user_db" ]; then
50     read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
51             USER_EXPIRE USER_DEVICES USER_FUTUREUSE <<-EOF
52         $(grep "^${user}        " "${user_db}")
53         EOF
54   fi
55   if [ "$USER_ID" -a "${USER_EXPIRE:-0}" -gt "$_DATE" ]; then
56        USER_NAME="$(UNSTRING "$USER_NAME")"
57       USER_EMAIL="$(UNSTRING "$USER_EMAIL")"
58     USER_DEVICES="$(UNSTRING "$USER_DEVICES")"
59     unset USER_PWSALT USER_PWHASH
60   else
61     eval "$UNSET_USER"
62     return 1
63   fi
64 }
65
66 update_user() {
67   # internal function for user update
68   local uid="$1" uname status email pwsalt pwhash expire devices futureuse
69   local UID UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
70   local arg
71
72   for arg in "$@"; do case $arg in
73     uname=*) uname="${arg#*=}";;
74     status=*) status="${arg#*=}";;
75     email=*) email="${arg#*=}";;
76     password=*) pwsalt="$(randomid)"; pwhash="$(user_pwhash "$pwsalt" "${arg#*=}")";;
77     expire=*) expire="${arg#*=}";;
78     devices=*) devices="${arg#*=}";;
79   esac; done
80
81   if LOCK "$user_db"; then
82     while read -r UID UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES \
83                   FUTUREUSE; do
84     if [ "$UID" = "$uid" ]; then
85       printf '%s        %s      %s      %s      %s      %s      %i      %s      %s\n' \
86              "$uid" "$(STRING "${uname-$(UNSTRING "$UNAME")}")" \
87              "${status:-${status-${STATUS}}${status+\\}}" \
88              "${email:-${email-${EMAIL}}${email+\\}}" \
89              "${pwsalt:-${PWSALT}}" "${pwhash:-${PWHASH}}" \
90              "${expire:-$((_DATE + 86400 * 730))}" \
91              "$(STRING "${devices-$(UNSTRING "$DEVICES")}")" \
92              "${FUTUREUSE:-\\}"
93     elif [ "$STATUS" = pending -a ! "$EXPIRE" -ge "$_DATE" ]; then
94       # omit expired invitations from output
95       :
96     else
97       printf '%s        %s      %s      %s      %s      %s      %i      %s      %s\n' \
98              "$UID" "$UNAME" "$STATUS" "$EMAIL" "$PWSALT" "$PWHASH" \
99              "$EXPIRE" "$DEVICES" "$FUTUREUSE"
100     fi
101     done <"$user_db" >"${user_db}.$$"
102     mv -- "${user_db}.$$" "$user_db"
103     RELEASE "$user_db"
104   else
105     return 1
106   fi
107 }
108
109 new_user(){
110   local user="${1:-$(timeid)}"
111   shift 1
112
113   if LOCK "$user_db"; then
114     if grep -q "^${user}        " "$user_db"; then
115       RELEASE "$user_db"
116       return 1
117     fi
118     printf '%s  \\      %s      \\      \\      \\      %i      \\      \\\n' \
119            "$user" "pending" "$(( $_DATE + 86400 ))" >>"$user_db"
120   else
121     return 1
122   fi
123
124   if [ $# -eq 0 ]; then
125     RELEASE "$user_db"
126     return 0
127   elif update_user "$user" "$@"; then
128     return 0
129   else
130     RELEASE "$user_db"
131     return 1
132   fi
133 }
134
135 user_idmap(){
136   local uid="$1" ret
137   eval "$LOCAL_USER"
138
139   if [ ! "$USER_IDMAP" ]; then
140     while read_user; do
141       USER_IDMAP="${USER_IDMAP}${USER_ID}       ${USER_NAME}${BR}"
142     done <"$user_db"
143   fi
144   if [ "$uid" -a "$USER_IDMAP" != "${USER_IDMAP##*${uid}        }" ]; then
145     ret="${USER_IDMAP##*${uid}  }"; ret="${ret%%${BR}*}";
146     printf '%s\n' "$ret"
147     return 0
148   elif [ "$uid" ]; then
149     return 1
150   else
151     printf '%s' "$USER_IDMAP"
152     return 0
153   fi
154 }
155
156 user_idof(){
157   local name="$(STRING "$1")" ret
158   [ "$USER_IDMAP" ] || user_idmap >/dev/null
159
160   if [ "${name%\\}" -a "$USER_IDMAP" != "${USER_IDMAP%  ${name}${BR}*}" ]; then
161     ret="${USER_IDMAP%  ${name}${BR}*}"; ret="${ret##*${BR}}"
162     printf '%s\n' "$ret"
163     return 0
164   else
165     return 1
166   fi
167 }
168
169 user_checkname(){
170   { [ $# -gt 0 ] && printf %s "$*" || cat; } \
171   | sed -nE '
172     :X; $!{N;bX;}
173     s;[ \t\r\n]+; ;g;
174     s;^ ;;; s; $;;;
175     /@/d;
176     /^[a-zA-Z][a-zA-Z0-9 -~]{2,127}$/!d;
177     p;
178     '
179 }
180
181 user_checkemail(){
182   { [ $# -gt 0 ] && printf %s "$*" || cat; } \
183   | sed -nE '
184     # W3C recommended email regex
185     # https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email)
186     /^[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;
187     '
188 }
189
190 user_nameexist(){
191   local uname="$(STRING "$1")"
192   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
193   [ -f "$user_db" -a -r "$user_db" ] \
194   && while read -r UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
195     [ "$EXPIRE" -gt "$_DATE" -a "$UNAME" = "$uname" ] && return 0
196   done <"$user_db"
197   return 1
198 }
199
200 user_emailexist(){
201   local email="$(STRING "$1")"
202   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
203   [ -f "$user_db" -a -r "$user_db" ] \
204   && while read -r UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
205     [ "$EXPIRE" -gt "$_DATE" -a "$EMAIL" = "$email" ] && return 0
206   done <"$user_db"
207   return 1
208 }
209
210 user_pwhash(){
211   local salt="$1" secret="$2" hash
212   hash="$(printf '%s\n%s\n' "$secret" "$salt" |sha256sum)"
213   printf '%s\n' "${hash%% *}"
214 }
215
216 user_register(){
217   # reserve account, send registration mail
218   # preliminary uid, expiration, signature
219   local uid="$(timeid)"
220   local uname="$(POST uname |user_checkname)"
221   local email="$(POST email |user_checkemail)"
222   local pwsalt="$(randomid)"
223   local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
224
225   if [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
226     REDIRECT "${_BASE}${PATH_INFO}#ERROR_REGISTRATION_DISABLED"
227   fi
228
229   if   [ "$USER_REQUIREEMAIL" = true ]; then
230     if [ ! "email" ]; then
231       REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
232     elif user_emailexist "$email"; then
233       REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
234     elif new_user "$uid" status=pending email="$email" expire="$((_DATE + 86400))"; then
235       debug "Sending Activation Link:" \
236             "https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
237       "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
238         From: ${MAILFROM}
239         To: ${email}
240         Subject: Your account registration at ${HTTP_HOST%:*}
241
242         Someone tried to sign up for a user account using this email address.
243
244         You can activate your account using this link:
245
246             https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
247
248         This registration link will expire after 24 hours.
249
250         If you did not request an account at ${HTTP_HOST%:*}, then someone else
251         probably entered your email address by accident. In this case you shoud
252         simply ignore this message and we will remove your email address from
253         our database within the next day.
254
255         This is an automatic email. Any direct reply will not be received.
256         Your Account Registration Robot.
257         EOF
258       REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
259     else
260       REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
261     fi
262
263   elif [ "$USER_REQUIREEMAIL" != true ]; then
264     if [ ! "$uname" ]; then
265       REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_INVALID"
266     elif user_nameexist "$uname"; then
267       REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_EXISTS"
268     elif [ ! "$pw" ]; then
269       REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_EMPTYTOOSHORT"
270     elif [ "$pw" != "$pwconfirm" ]; then
271       REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
272     elif new_user "$uid" uname="$uname" status=active email="$email" password="$pw" expire="$((_DATE + 86400 * 730))"; then
273       SESSION_COOKIE new
274       SESSION_BIND user_id "$uid"
275
276       if [ "$USER_ACCOUNTPAGE" ]; then
277         REDIRECT "${USER_ACCOUNTPAGE}"
278       else
279         REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
280       fi
281     else
282       REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
283     fi
284   fi
285 }
286
287 user_invite(){
288   local uid="$(timeid)"
289   local email="$(POST email |user_checkemail)"
290   local message="$(POST message)"
291
292   if [ ! "email" ]; then
293     REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
294   elif user_emailexist "$email"; then
295     REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
296   elif new_user "$uid" status=pending email="$email" expire="$((_DATE + 86400))"; then
297     debug "Sending Invitation Link:" \
298           "https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
299     "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
300         From: ${MAILFROM}
301         To: ${email}
302         Subject: You have been invited to ${HTTP_HOST%:*}
303
304         ${USER_NAME:-Someone} has offered an invitation to this email address.
305
306         ${message}
307
308         You can create your account using this link:
309
310             https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
311
312         This registration link will expire after 24 hours.
313
314         If you do not know what this is about, then someone else probably
315         entered your email address by accident. In this case you shoud
316         simply ignore this message and we will remove your email address from
317         our database within the next day.
318
319         This is an automatic email. Any direct reply will not be received.
320         Your Account Registration Robot.
321         EOF
322     REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
323   else
324     REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
325   fi
326 }
327
328 user_confirm(){
329   # enable account
330   eval "$LOCAL_USER"
331   local uid="$(POST uid |checkid || printf invalid)"
332   local signature="$(POST signature)"
333   local uname="$(POST uname |user_checkname)"
334   local pwsalt="$(randomid)"
335   local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
336
337   read_user "${uid}"
338
339   if [ "$signature" != "$(session_mac "$uid")" ]; then
340     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
341   elif [ ! "$uname" ]; then
342     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_INVALID"
343   elif user_nameexist "$uname"; then
344     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_EXISTS"
345   elif [ ! "$pw" ]; then
346     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_EMPTYTOOSHORT"
347   elif [ "$pw" != "$pwconfirm" ]; then
348     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_MISMATCH"
349   elif [ "$USER_STATUS" != pending -o \! "$USER_EXPIRE" -gt "$_DATE" ]; then
350     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
351   elif update_user "$USER_ID" uname="$uname" status=active password="$pw"; then
352     SESSION_COOKIE new
353     SESSION_BIND user_id "$USER_ID"
354     if [ "$USER_ACCOUNTPAGE" ]; then
355       REDIRECT "${USER_ACCOUNTPAGE}"
356     else
357       REDIRECT "${_BASE}${PATH_INFO}?user_register=confirm#USER_REGISTER_CONFIRM"
358     fi
359   else
360     REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
361   fi
362 }
363
364 user_login(){
365   # set cookie
366   # keep logged in - device cookie?
367   # initialize new session!
368   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
369   local uname="$(POST uname |STRING)" pw="$(POST pw)"
370
371   [ -f "$user_db" -a -r "$user_db" ] \
372   && while read -r UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
373     if [ "$UNAME" = "$uname" -o "$EMAIL" = "$uname" ]; then
374       if [ "$STATUS" = active -a "$EXPIRE" -gt "$_DATE" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$pw")" ]; then
375         SESSION_COOKIE new
376         SESSION_BIND user_id "$UID"
377         REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_IN"
378       fi
379     fi
380   done <"$user_db"
381   REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_LOGIN"
382 }
383
384 user_logout(){
385   # destroy cookie, destroy session
386   # keep device cookie
387   new_session
388   SESSION_COOKIE new
389   SET_COOKIE 0 user_id="" Path="/${_BASE#/}" SameSite=Strict HttpOnly
390   REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_OUT"
391 }
392
393 user_update(){
394   # passphrase, email
395   :
396 }
397 user_recover(){
398   # send recover link
399   :
400 }
401 user_disable(){
402   :
403 }
404
405 read_user "$(SESSION_VAR user_id)"
406 [ "$USER_STATUS" -a "$USER_STATUS" != active ] && eval $UNSET_USER
407
408 [ "$REQUEST_METHOD" = POST ] && case "$(POST action)" in
409   user_register) user_register ;;
410   user_confirm)  user_confirm ;;
411   user_invite)   user_invite ;;
412   user_login)    user_login ;;
413   user_logout)   user_logout ;;
414   user_update)
415     :;;
416   user_recover)
417     :;;
418   user_disable)
419     :;;
420 esac
421
422 w_user_register(){
423   if [ "$(GET user_confirm)" ]; then
424     w_user_confirm
425   elif [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
426     cat <<-EOF
427         [div #user_register .disabled
428         User Registration is disabled.
429         ]
430         EOF
431   elif [ "$USER_REQUIREEMAIL" = true ]; then
432     cat <<-EOF
433         [form #user_register .registeremail method=POST
434           [p We will send an activation mail to your email address.
435             You can continue the signup process when you click on the
436             activation link in this email.]
437           [input type=email name=email placeholder="Email"]
438           [submit "action" "user_register" Sign Up]
439         ]
440         EOF
441   elif [ "$USER_REQUIREEMAIL" != true ]; then
442     cat <<-EOF
443         [form #user_register .registername method=POST
444           [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="^\[a-zA-Z\]\[a-zA-Z0-9 -~\]{2,127}$" autocomplete=off]
445           [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
446           [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
447           [submit "action" "user_register" Sign Up]
448         ]
449         EOF
450   fi
451 }
452
453 w_user_confirm(){
454   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
455   local user_confirm="$(GET user_confirm)"
456   local uid="${user_confirm% *}" signature="${user_confirm#* }"
457
458   if [ "$signature" = "$(session_mac "$uid")" ]; then
459     read -r UID UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE <<-EOF
460         $(grep "^${uid} " "$user_db")
461         EOF
462     if [ "$STATUS" = pending -a "$EXPIRE" -gt "$_DATE" ]; then
463       cat <<-EOF
464         [form #user_confirm method=POST
465           [input type=hidden name=uid value="${uid}"]
466           [input type=hidden name=signature value="${signature}"]
467           $([ "$EMAIL" != '\' ] && printf \
468             '[input disabled=disabled value="%s" placeholder="Email"]' "$(UNSTRING "$EMAIL" |HTML)"
469           )
470           [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="^\[a-zA-Z\]\[a-zA-Z0-9 -~\]{2,127}$" autocomplete=off]
471           [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
472           [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
473           [submit "action" "user_confirm" Finish Registration]
474         ]
475         EOF
476     else
477       cat <<-EOF
478         [div #user_confirm .expired
479           [p This activation link is not valid anymore.]
480         ]
481         EOF
482     fi
483   else
484     cat <<-EOF
485         [div #user_confirm .invalid
486           [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.]
487         ]
488         EOF
489   fi
490 }
491
492 w_user_invite(){
493   if [ "$(GET user_confirm)" ]; then
494     w_user_confirm
495   elif [ "$USER_ID" -a "$SENDMAIL" ]; then
496     cat <<-EOF
497         [form #user_invite method=POST
498           [input placeholder="Email Recipient" name=email autocomplete=off]
499           [textarea name="message" placeholder="Message to recipient" . ]
500           [submit "action" "user_invite" Send Invitation]
501         ]
502         EOF
503   elif [ "$USER_ID" ]; then
504     uid="$(timeid)"
505     new_user "$uid" status=pending email="$email" expire="$((_DATE + 86400))"
506     cat <<-EOF
507         [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 24 hours.]
508         [p . $(HTML "https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")" |debug)]
509
510         [p [a href="#" . Set up another account]]
511         EOF
512   else
513     cat <<-EOF
514         [div #user_invite .notallowed
515           Only registered users may send an invitation to another user.
516         ]
517         EOF
518   fi
519 }
520
521 w_user_login(){
522   if [ ! "$USER_ID" ]; then
523     cat <<-EOF
524         [form #user_login .login method=POST
525           [input name=uname placeholder="Username or Email" autocomplete=off]
526           [input type=password name=pw placeholder="Passphrase"]
527           [submit "action" "user_login" Login]
528         ]
529         EOF
530   elif [ "$USER_ID" ]; then
531     cat <<-EOF
532         [form #user_login .logout method=POST
533           [p Logged in as [span . $(HTML ${USER_NAME})]]
534           [submit "action" "user_logout" Logout]
535         ]
536         EOF
537   fi
538 }