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