@@ -49,6 +49,17 @@ local validate_token = validations.validate_token
4949local utils = require (' lib.util' )
5050local escape_html = utils .escape_html
5151
52+ -- Rotate the target user's remember_token (kicking every other session
53+ -- for that account) and, if the target IS the requestor, slide the new
54+ -- token into the current cookie so the requestor stays logged in.
55+ local function rotate_token_and_sync_session (self , user )
56+ if not user then return end
57+ local new_token = user :rotate_remember_token ()
58+ if self .current_user and user .id == self .current_user .id then
59+ self .session .remember_token = new_token
60+ end
61+ end
62+
5263UserController = {
5364 run_query = function (self , query )
5465 if not self .params .page_number then self .params .page_number = 1 end
@@ -180,8 +191,9 @@ UserController = {
180191 token :delete ()
181192 end
182193
183- -- TODO: Create and store a remember token
184194 self .session .username = self .queried_user .username
195+ self .session .remember_token =
196+ self .queried_user :ensure_remember_token ()
185197 self .session .persist_session = tostring (self .params .persist )
186198 self .queried_user :update ({
187199 last_login_at = db .format_date ()
@@ -204,12 +216,28 @@ UserController = {
204216 -- Admins can log in as other people
205217 assert_admin (self , err .wrong_password )
206218 self .session .username = self .queried_user .username
219+ self .session .remember_token =
220+ self .queried_user :ensure_remember_token ()
207221 return jsonResponse ({ redirect = self :build_url (' /' ) })
208222 end
209223 end ),
224+ -- POST /logout
225+ -- Optional param: all_sessions=true rotates the user's remember_token
226+ -- before clearing the cookie, which invalidates every other session
227+ -- that was authenticated with the previous token.
210228 logout = capture_errors (function (self )
229+ if self .params .all_sessions == true
230+ or tostring (self .params .all_sessions ) == ' true' then
231+ local user = self .current_user
232+ or (self .session .remember_token and self .session .remember_token ~= ' '
233+ and Users :find ({ remember_token = self .session .remember_token }))
234+ if user then user :rotate_remember_token () end
235+ end
211236 self .session .username = ' '
212237 self .session .user_id = nil
238+ self .session .remember_token = nil
239+ self .session .impersonator = nil
240+ self .session .impersonator_token = nil
213241 self .session .persist_session = ' false'
214242 return jsonResponse ({
215243 redirect = (self .params .redirect or self :build_url (' /' ))
@@ -258,6 +286,10 @@ UserController = {
258286 )
259287 end
260288
289+ -- Email is the recovery channel — if it changed under an attacker,
290+ -- every existing session for this account must die.
291+ rotate_token_and_sync_session (self , user )
292+
261293 return jsonResponse ({
262294 title = ' Email changed' ,
263295 message = ' Email has been updated.' ,
@@ -306,6 +338,10 @@ UserController = {
306338 )
307339 end
308340
341+ -- A username change is a strong account-takeover signal; rotate
342+ -- the token so any other live session for this account is dropped.
343+ rotate_token_and_sync_session (self , user )
344+
309345 return jsonResponse ({
310346 title = ' Username changed' ,
311347 message = ' Username has been updated.' ,
@@ -330,7 +366,13 @@ UserController = {
330366 password = bcrypt_hash (self .params .new_password ),
331367 password_version = PASSWORD_VERSION_BCRYPT ,
332368 salt = ' ' ,
369+ password_changed_at = db .raw (' now()' ),
370+ updated_at = db .raw (' now()' )
333371 })
372+ -- Rotate the remember_token so every other device gets logged out.
373+ -- Refresh the current session's token so the caller stays logged in.
374+ self .session .remember_token =
375+ self .current_user :rotate_remember_token ()
334376 return jsonResponse ({
335377 title = ' Password changed' ,
336378 message = ' Your password has been changed.' ,
@@ -423,6 +465,7 @@ UserController = {
423465 -- we've deleted ourselves, let's log out
424466 self .session .username = ' '
425467 self .session .user_id = nil
468+ self .session .remember_token = nil
426469 self .session .persist_session = ' false'
427470 end
428471 return jsonResponse ({
@@ -690,9 +733,17 @@ UserController = {
690733 Users .roles [self .queried_user .role ] then
691734 yield_error (err .auth )
692735 else
736+ -- Stash the admin's identity so unbecome can restore it.
737+ -- We save both username and remember_token so the admin's
738+ -- session keeps working even if we swap the cookie's token
739+ -- to the target user's.
693740 self .session .impersonator = self .current_user .username
741+ self .session .impersonator_token =
742+ self .session .remember_token
694743 self .current_user = self .queried_user
695744 self .session .username = self .queried_user .username
745+ self .session .remember_token =
746+ self .queried_user :ensure_remember_token ()
696747 end
697748 end
698749 return jsonResponse ({
@@ -704,9 +755,11 @@ UserController = {
704755 unbecome = capture_errors (function (self )
705756 if self .session .impersonator then
706757 self .session .username = self .session .impersonator
758+ self .session .remember_token = self .session .impersonator_token
707759 self .current_user =
708760 Users :find ({ username = self .session .impersonator })
709761 self .session .impersonator = nil
762+ self .session .impersonator_token = nil
710763 return jsonResponse ({
711764 message = ' You are now ' .. self .session .username .. ' again' ,
712765 title = ' Unimpersonation' ,
@@ -732,6 +785,11 @@ UserController = {
732785 if self .queried_user then
733786 assert_can_set_role (self , self .params .role )
734787 self .queried_user :update ({ role = self .params .role })
788+ -- A banned user must lose every live session — otherwise an
789+ -- attacker who keeps an existing cookie keeps their access.
790+ if self .params .role == ' banned' then
791+ rotate_token_and_sync_session (self , self .queried_user )
792+ end
735793 end
736794 return jsonResponse ({
737795 message =
@@ -741,6 +799,20 @@ UserController = {
741799 redirect = self .queried_user :url_for (' site' )
742800 })
743801 end ),
802+ -- Admin/moderator action: rotate a user's remember_token, which
803+ -- invalidates every active session for that account on every device.
804+ -- Use this for suspected account takeover or as a manual "kick" tool.
805+ force_logout = capture_errors (function (self )
806+ assert_min_role (self , ' moderator' )
807+ assert_user_exists (self )
808+ rotate_token_and_sync_session (self , self .queried_user )
809+ return jsonResponse ({
810+ title = ' Sessions terminated' ,
811+ message = ' All sessions for ' ..
812+ self .queried_user .username .. ' have been terminated.' ,
813+ redirect = self .queried_user :url_for (' site' )
814+ })
815+ end ),
744816 set_teacher = capture_errors (function (self )
745817 assert_min_role (self , ' moderator' )
746818 if self .queried_user then
@@ -861,6 +933,7 @@ app:match(
861933 self .username = escape_html (token .username )
862934 self .csrf_token = csrf .generate_token (self )
863935 self .min_password_length = Users .MIN_PASSWORD_LENGTH
936+ utils .set_no_frame_headers (self )
864937 return { render = ' password_reset' }
865938 end
866939 ),
@@ -869,6 +942,7 @@ app:match(
869942 -- Step 2: User submitted the new password form.
870943 -- Validate CSRF token first, without consuming
871944 -- the reset token.
945+ utils .set_no_frame_headers (self )
872946 local valid , csrf_err = csrf .validate_token (self )
873947 if not valid then
874948 return html_message_page (
@@ -906,13 +980,23 @@ app:match(
906980 ' password_reset' ,
907981 function (user )
908982 -- Store as native bcrypt (version 2) on reset.
983+ -- Rotate the remember_token in the same UPDATE so
984+ -- every existing session for this user is logged
985+ -- out — including any session the attacker may
986+ -- still hold. The user re-logs in fresh.
909987 user :update ({
910988 password = bcrypt_hash (prehash ),
911989 password_version = PASSWORD_VERSION_BCRYPT ,
912990 salt = ' ' ,
913991 password_changed_at = db .raw (' now()' ),
914- updated_at = db .raw (' now()' )
992+ updated_at = db .raw (' now()' ),
993+ remember_token = secure_token ()
915994 })
995+ -- Drop any session this browser still has so the
996+ -- "log in" link below is not a no-op.
997+ self .session .username = ' '
998+ self .session .remember_token = nil
999+ self .session .user_id = nil
9161000 send_mail (
9171001 user .email ,
9181002 mail_subjects .password_changed ..
0 commit comments