11import assert from 'node:assert/strict'
2+ import crypto from 'node:crypto'
23import { describe , it , after , before } from 'node:test'
34
45import User from './index.js'
56import Group from '../group/index.js'
7+ import Mysql from '../mysql.js'
68
79import userCase from '../test/user.json' with { type : 'json' }
810import groupCase from '../test/group.json' with { type : 'json' }
@@ -77,27 +79,35 @@ describe('user', function () {
7779 describe ( 'validPassword' , function ( ) {
7880 it ( 'auths user with plain text password' , async ( ) => {
7981 const r = await User . validPassword ( 'test' , 'test' , 'demo' , '' )
80- assert . equal ( r , true )
82+ assert . deepEqual ( r , { valid : true , needsUpgrade : true } )
8183 } )
8284
83- it ( 'auths valid pbkdb2 password' , async ( ) => {
84- const r = await User . validPassword (
85- 'YouGuessedIt!' ,
86- '050cfa70c3582be0d5bfae25138a8486dc2e6790f39bc0c4e111223ba6034432' ,
87- 'unit-test' ,
88- '(ICzAm2.QfCa6.MN' ,
89- )
90- assert . equal ( r , true )
85+ it ( 'auths valid self-describing PBKDF2 password' , async ( ) => {
86+ const salt = '(ICzAm2.QfCa6.MN'
87+ const hash = await User . hashForStorage ( 'YouGuessedIt!' , salt )
88+ const r = await User . validPassword ( 'YouGuessedIt!' , hash , 'unit-test' , salt )
89+ assert . deepEqual ( r , { valid : true , needsUpgrade : false } )
9190 } )
9291
93- it ( 'rejects invalid pbkdb2 password' , async ( ) => {
94- const r = await User . validPassword (
95- 'YouMissedIt!' ,
96- '050cfa70c3582be0d5bfae25138a8486dc2e6790f39bc0c4e111223ba6034432' ,
97- 'unit-test' ,
98- '(ICzAm2.QfCa6.MN' ,
99- )
100- assert . equal ( r , false )
92+ it ( 'rejects invalid self-describing PBKDF2 password' , async ( ) => {
93+ const salt = '(ICzAm2.QfCa6.MN'
94+ const hash = await User . hashForStorage ( 'YouGuessedIt!' , salt )
95+ const r = await User . validPassword ( 'YouMissedIt!' , hash , 'unit-test' , salt )
96+ assert . deepEqual ( r , { valid : false , needsUpgrade : false } )
97+ } )
98+
99+ it ( 'auths valid legacy PBKDF2-5000 password' , async ( ) => {
100+ const salt = '(ICzAm2.QfCa6.MN'
101+ const hash = await User . hashAuthPbkdf2 ( 'YouGuessedIt!' , salt , 5000 )
102+ const r = await User . validPassword ( 'YouGuessedIt!' , hash , 'unit-test' , salt )
103+ assert . deepEqual ( r , { valid : true , needsUpgrade : true } )
104+ } )
105+
106+ it ( 'rejects invalid legacy PBKDF2-5000 password' , async ( ) => {
107+ const salt = '(ICzAm2.QfCa6.MN'
108+ const hash = await User . hashAuthPbkdf2 ( 'YouGuessedIt!' , salt , 5000 )
109+ const r = await User . validPassword ( 'YouMissedIt!' , hash , 'unit-test' , salt )
110+ assert . deepEqual ( r , { valid : false , needsUpgrade : false } )
101111 } )
102112
103113 it ( 'auths valid SHA1 password' , async ( ) => {
@@ -106,7 +116,7 @@ describe('user', function () {
106116 '083007777a5241d01abba70c938c60d80be60027' ,
107117 'unit-test' ,
108118 )
109- assert . equal ( r , true )
119+ assert . deepEqual ( r , { valid : true , needsUpgrade : true } )
110120 } )
111121
112122 it ( 'rejects invalid SHA1 password' , async ( ) => {
@@ -115,7 +125,7 @@ describe('user', function () {
115125 '083007777a5241d01abba7Oc938c60d80be60027' ,
116126 'unit-test' ,
117127 )
118- assert . equal ( r , false )
128+ assert . deepEqual ( r , { valid : false , needsUpgrade : false } )
119129 } )
120130 } )
121131
@@ -144,4 +154,122 @@ describe('user', function () {
144154 assert . ok ( u )
145155 } )
146156 } )
157+
158+ describe ( 'password upgrade on login' , ( ) => {
159+ const upgradeUserId = 4200
160+ const upgradeUser = {
161+ nt_user_id : upgradeUserId ,
162+ nt_group_id : groupCase . id ,
163+ username : 'upgrade-test' ,
164+ email : 'upgrade-test@example.com' ,
165+ first_name : 'Upgrade' ,
166+ last_name : 'Test' ,
167+ }
168+ const testPass = 'UpgradeMe!123'
169+ const authCreds = {
170+ username : `${ upgradeUser . username } @${ groupCase . name } ` ,
171+ password : testPass ,
172+ }
173+
174+ async function getDbPassword ( ) {
175+ const rows = await Mysql . execute (
176+ 'SELECT password, pass_salt FROM nt_user WHERE nt_user_id = ?' ,
177+ [ upgradeUserId ] ,
178+ )
179+ return rows [ 0 ]
180+ }
181+
182+ // Raw SQL so we can insert specific legacy password formats (plain text,
183+ // SHA-1, PBKDF2-5000) that User.create() would hash on the way in.
184+ async function insertUser ( password , passSalt ) {
185+ await Mysql . execute (
186+ 'INSERT INTO nt_user (nt_user_id, nt_group_id, username, email, first_name, last_name, password, pass_salt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ,
187+ [ upgradeUserId , upgradeUser . nt_group_id , upgradeUser . username , upgradeUser . email , upgradeUser . first_name , upgradeUser . last_name , password , passSalt ] ,
188+ )
189+ }
190+
191+ async function cleanup ( ) {
192+ await Mysql . execute (
193+ 'DELETE FROM nt_user WHERE nt_user_id = ?' ,
194+ [ upgradeUserId ] ,
195+ )
196+ }
197+
198+ before ( cleanup )
199+ after ( cleanup )
200+
201+ it ( 'upgrades plain text password to self-describing PBKDF2 on login' , async ( ) => {
202+ await cleanup ( )
203+ await insertUser ( testPass , null )
204+
205+ const result = await User . authenticate ( authCreds )
206+ assert . ok ( result , 'login should succeed' )
207+
208+ const row = await getDbPassword ( )
209+ assert . ok ( row . pass_salt , 'pass_salt should be set after upgrade' )
210+ assert . notEqual ( row . password , testPass , 'password should be hashed' )
211+ assert . ok ( row . password . includes ( '$' ) , 'password should be in self-describing format' )
212+
213+ // verify round-trip: can still log in with the upgraded hash
214+ const again = await User . authenticate ( authCreds )
215+ assert . ok ( again , 'login should succeed after upgrade' )
216+ await cleanup ( )
217+ } )
218+
219+ it ( 'upgrades SHA1 password to self-describing PBKDF2 on login' , async ( ) => {
220+ // authenticate() passes the full authTry.username (including @group) to
221+ // validPassword(), so the HMAC key must match that full string
222+ const sha1Hash = crypto
223+ . createHmac ( 'sha1' , authCreds . username . toLowerCase ( ) )
224+ . update ( testPass )
225+ . digest ( 'hex' )
226+ await cleanup ( )
227+ await insertUser ( sha1Hash , null )
228+
229+ const result = await User . authenticate ( authCreds )
230+ assert . ok ( result , 'login should succeed with SHA1 hash' )
231+
232+ const row = await getDbPassword ( )
233+ assert . ok ( row . pass_salt , 'pass_salt should be set after upgrade' )
234+ assert . notEqual ( row . password , sha1Hash , 'password should be re-hashed' )
235+ assert . ok ( row . password . includes ( '$' ) , 'password should be in self-describing format' )
236+
237+ const again = await User . authenticate ( authCreds )
238+ assert . ok ( again , 'login should succeed after upgrade' )
239+ await cleanup ( )
240+ } )
241+
242+ it ( 'upgrades PBKDF2-5000 to self-describing format on login' , async ( ) => {
243+ const legacySalt = User . generateSalt ( )
244+ const legacyHash = await User . hashAuthPbkdf2 ( testPass , legacySalt , 5000 )
245+ await cleanup ( )
246+ await insertUser ( legacyHash , legacySalt )
247+
248+ const result = await User . authenticate ( authCreds )
249+ assert . ok ( result , 'login should succeed with legacy PBKDF2' )
250+
251+ const row = await getDbPassword ( )
252+ assert . notEqual ( row . password , legacyHash , 'password should be re-hashed' )
253+ assert . notEqual ( row . pass_salt , legacySalt , 'salt should be regenerated' )
254+ assert . ok ( row . password . includes ( '$' ) , 'password should be in self-describing format' )
255+
256+ const again = await User . authenticate ( authCreds )
257+ assert . ok ( again , 'login should succeed after upgrade' )
258+ await cleanup ( )
259+ } )
260+
261+ it ( 'does not re-hash password already in self-describing format' , async ( ) => {
262+ const salt = User . generateSalt ( )
263+ const hash = await User . hashForStorage ( testPass , salt )
264+ await cleanup ( )
265+ await insertUser ( hash , salt )
266+
267+ await User . authenticate ( authCreds )
268+
269+ const row = await getDbPassword ( )
270+ assert . equal ( row . password , hash , 'password should be unchanged' )
271+ assert . equal ( row . pass_salt , salt , 'salt should be unchanged' )
272+ await cleanup ( )
273+ } )
274+ } )
147275} )
0 commit comments