Skip to content

Commit fd0b535

Browse files
acinaderdplewis
andauthored
Case insensitive signup (#5634)
* Always delete data after each, even for mongo. * Add failing simple case test * run all tests * 1. when validating username be case insensitive 2. add _auth_data_anonymous to specialQueryKeys...whatever that is! * More case sensitivity 1. also make email validation case insensitive 2. update comments to reflect what this change does * wordsmithery and grammar * first pass at a preformant case insensitive query. mongo only so far. * change name of parameter from insensitive to caseInsensitive * Postgres support * properly handle auth data null * wip * use 'caseInsensitive' instead of 'insensitive' in all places. * update commenet to reclect current plan * skip the mystery test for now * create case insensitive indecies for mongo to support case insensitive checks for email and username * remove unneeded specialKey * pull collation out to a function. * not sure what i planned to do with this test. removing. * remove typo * remove another unused flag * maintain order * maintain order of params * boil the ocean on param sequence i like having explain last cause it seems like something you would change/remove after getting what you want from the explain? * add test to verify creation and use of caseInsensitive index * add no op func to prostgress * get collation object from mongocollection make flow lint happy by declaring things Object. * fix typo * add changelog * kick travis * properly reference static method * add a test to confirm that anonymous users with unique username that do collide when compared insensitively can still be created. * minot doc nits * add a few tests to make sure our spy is working as expected wordsmith the changelog Co-authored-by: Diamond Lewis <[email protected]>
1 parent 1ea3f86 commit fd0b535

10 files changed

+413
-35
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
### master
44

55
[Full Changelog](https://github.com/parse-community/parse-server/compare/3.10.0...master)
6+
- FIX: FIX: Prevent new usernames or emails that clash with existing users' email or username if it only differs by case. For example, don't allow a new user with the name 'Jane' if we already have a user 'jane'. [#5634](https://github.com/parse-community/parse-server/pull/5634). Thanks to [Arthur Cinader](https://github.com/acinader)
67

78
### 3.10.0
89
[Full Changelog](https://github.com/parse-community/parse-server/compare/3.9.0...3.10.0)

spec/MongoStorageAdapter.spec.js

+32
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,38 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
318318
);
319319
});
320320

321+
it('should use index for caseInsensitive query', async () => {
322+
const user = new Parse.User();
323+
user.set('username', 'Bugs');
324+
user.set('password', 'Bunny');
325+
await user.signUp();
326+
327+
const database = Config.get(Parse.applicationId).database;
328+
const preIndexPlan = await database.find(
329+
'_User',
330+
{ username: 'bugs' },
331+
{ caseInsensitive: true, explain: true }
332+
);
333+
334+
const schema = await new Parse.Schema('_User').get();
335+
336+
await database.adapter.ensureIndex(
337+
'_User',
338+
schema,
339+
['username'],
340+
'case_insensitive_username',
341+
true
342+
);
343+
344+
const postIndexPlan = await database.find(
345+
'_User',
346+
{ username: 'bugs' },
347+
{ caseInsensitive: true, explain: true }
348+
);
349+
expect(preIndexPlan.executionStats.executionStages.stage).toBe('COLLSCAN');
350+
expect(postIndexPlan.executionStats.executionStages.stage).toBe('FETCH');
351+
});
352+
321353
if (
322354
process.env.MONGODB_VERSION === '4.0.4' &&
323355
process.env.MONGODB_TOPOLOGY === 'replicaset' &&

spec/ParseUser.spec.js

+123
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageA
1212
const request = require('../lib/request');
1313
const passwordCrypto = require('../lib/password');
1414
const Config = require('../lib/Config');
15+
const cryptoUtils = require('../lib/cryptoUtils');
1516

1617
function verifyACL(user) {
1718
const ACL = user.getACL();
@@ -2244,6 +2245,128 @@ describe('Parse.User testing', () => {
22442245
);
22452246
});
22462247

2248+
describe('case insensitive signup not allowed', () => {
2249+
it('signup should fail with duplicate case insensitive username with basic setter', async () => {
2250+
const user = new Parse.User();
2251+
user.set('username', 'test1');
2252+
user.set('password', 'test');
2253+
await user.signUp();
2254+
2255+
const user2 = new Parse.User();
2256+
user2.set('username', 'Test1');
2257+
user2.set('password', 'test');
2258+
await expectAsync(user2.signUp()).toBeRejectedWith(
2259+
new Parse.Error(
2260+
Parse.Error.USERNAME_TAKEN,
2261+
'Account already exists for this username.'
2262+
)
2263+
);
2264+
});
2265+
2266+
it('signup should fail with duplicate case insensitive username with field specific setter', async () => {
2267+
const user = new Parse.User();
2268+
user.setUsername('test1');
2269+
user.setPassword('test');
2270+
await user.signUp();
2271+
2272+
const user2 = new Parse.User();
2273+
user2.setUsername('Test1');
2274+
user2.setPassword('test');
2275+
await expectAsync(user2.signUp()).toBeRejectedWith(
2276+
new Parse.Error(
2277+
Parse.Error.USERNAME_TAKEN,
2278+
'Account already exists for this username.'
2279+
)
2280+
);
2281+
});
2282+
2283+
it('signup should fail with duplicate case insensitive email', async () => {
2284+
const user = new Parse.User();
2285+
user.setUsername('test1');
2286+
user.setPassword('test');
2287+
user.setEmail('[email protected]');
2288+
await user.signUp();
2289+
2290+
const user2 = new Parse.User();
2291+
user2.setUsername('test2');
2292+
user2.setPassword('test');
2293+
user2.setEmail('[email protected]');
2294+
await expectAsync(user2.signUp()).toBeRejectedWith(
2295+
new Parse.Error(
2296+
Parse.Error.EMAIL_TAKEN,
2297+
'Account already exists for this email address.'
2298+
)
2299+
);
2300+
});
2301+
2302+
it('edit should fail with duplicate case insensitive email', async () => {
2303+
const user = new Parse.User();
2304+
user.setUsername('test1');
2305+
user.setPassword('test');
2306+
user.setEmail('[email protected]');
2307+
await user.signUp();
2308+
2309+
const user2 = new Parse.User();
2310+
user2.setUsername('test2');
2311+
user2.setPassword('test');
2312+
user2.setEmail('[email protected]');
2313+
await user2.signUp();
2314+
2315+
user2.setEmail('[email protected]');
2316+
await expectAsync(user2.save()).toBeRejectedWith(
2317+
new Parse.Error(
2318+
Parse.Error.EMAIL_TAKEN,
2319+
'Account already exists for this email address.'
2320+
)
2321+
);
2322+
});
2323+
2324+
describe('anonymous users', () => {
2325+
beforeEach(() => {
2326+
const insensitiveCollisions = [
2327+
'abcdefghijklmnop',
2328+
'Abcdefghijklmnop',
2329+
'ABcdefghijklmnop',
2330+
'ABCdefghijklmnop',
2331+
'ABCDefghijklmnop',
2332+
'ABCDEfghijklmnop',
2333+
'ABCDEFghijklmnop',
2334+
'ABCDEFGhijklmnop',
2335+
'ABCDEFGHijklmnop',
2336+
'ABCDEFGHIjklmnop',
2337+
'ABCDEFGHIJklmnop',
2338+
'ABCDEFGHIJKlmnop',
2339+
'ABCDEFGHIJKLmnop',
2340+
'ABCDEFGHIJKLMnop',
2341+
'ABCDEFGHIJKLMnop',
2342+
'ABCDEFGHIJKLMNop',
2343+
'ABCDEFGHIJKLMNOp',
2344+
'ABCDEFGHIJKLMNOP',
2345+
];
2346+
2347+
// need a bunch of spare random strings per api request
2348+
spyOn(cryptoUtils, 'randomString').and.returnValues(
2349+
...insensitiveCollisions
2350+
);
2351+
});
2352+
2353+
it('should not fail on case insensitive matches', async () => {
2354+
const user1 = await Parse.AnonymousUtils.logIn();
2355+
const username1 = user1.get('username');
2356+
2357+
const user2 = await Parse.AnonymousUtils.logIn();
2358+
const username2 = user2.get('username');
2359+
2360+
expect(username1).not.toBeUndefined();
2361+
expect(username2).not.toBeUndefined();
2362+
expect(username1.toLowerCase()).toBe('abcdefghijklmnop');
2363+
expect(username2.toLowerCase()).toBe('abcdefghijklmnop');
2364+
expect(username2).not.toBe(username1);
2365+
expect(username2.toLowerCase()).toBe(username1.toLowerCase()); // this is redundant :).
2366+
});
2367+
});
2368+
});
2369+
22472370
it('user cannot update email to existing user', done => {
22482371
const user = new Parse.User();
22492372
user.set('username', 'test1');

spec/helper.js

+1-7
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,7 @@ afterEach(function(done) {
207207
'There were open connections to the server left after the test finished'
208208
);
209209
}
210-
on_db(
211-
'postgres',
212-
() => {
213-
TestUtils.destroyAllDataPermanently(true).then(done, done);
214-
},
215-
done
216-
);
210+
TestUtils.destroyAllDataPermanently(true).then(done, done);
217211
};
218212
Parse.Cloud._removeAllHooks();
219213
databaseAdapter

src/Adapters/Storage/Mongo/MongoCollection.js

+37-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@ export default class MongoCollection {
1515
// idea. Or even if this behavior is a good idea.
1616
find(
1717
query,
18-
{ skip, limit, sort, keys, maxTimeMS, readPreference, hint, explain } = {}
18+
{
19+
skip,
20+
limit,
21+
sort,
22+
keys,
23+
maxTimeMS,
24+
readPreference,
25+
hint,
26+
caseInsensitive,
27+
explain,
28+
} = {}
1929
) {
2030
// Support for Full Text Search - $text
2131
if (keys && keys.$score) {
@@ -30,6 +40,7 @@ export default class MongoCollection {
3040
maxTimeMS,
3141
readPreference,
3242
hint,
43+
caseInsensitive,
3344
explain,
3445
}).catch(error => {
3546
// Check for "no geoindex" error
@@ -60,16 +71,34 @@ export default class MongoCollection {
6071
maxTimeMS,
6172
readPreference,
6273
hint,
74+
caseInsensitive,
6375
explain,
6476
})
6577
)
6678
);
6779
});
6880
}
6981

82+
/**
83+
* Collation to support case insensitive queries
84+
*/
85+
static caseInsensitiveCollation() {
86+
return { locale: 'en_US', strength: 2 };
87+
}
88+
7089
_rawFind(
7190
query,
72-
{ skip, limit, sort, keys, maxTimeMS, readPreference, hint, explain } = {}
91+
{
92+
skip,
93+
limit,
94+
sort,
95+
keys,
96+
maxTimeMS,
97+
readPreference,
98+
hint,
99+
caseInsensitive,
100+
explain,
101+
} = {}
73102
) {
74103
let findOperation = this._mongoCollection.find(query, {
75104
skip,
@@ -83,6 +112,12 @@ export default class MongoCollection {
83112
findOperation = findOperation.project(keys);
84113
}
85114

115+
if (caseInsensitive) {
116+
findOperation = findOperation.collation(
117+
MongoCollection.caseInsensitiveCollation()
118+
);
119+
}
120+
86121
if (maxTimeMS) {
87122
findOperation = findOperation.maxTimeMS(maxTimeMS);
88123
}

src/Adapters/Storage/Mongo/MongoStorageAdapter.js

+52-1
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,16 @@ export class MongoStorageAdapter implements StorageAdapter {
620620
className: string,
621621
schema: SchemaType,
622622
query: QueryType,
623-
{ skip, limit, sort, keys, readPreference, hint, explain }: QueryOptions
623+
{
624+
skip,
625+
limit,
626+
sort,
627+
keys,
628+
readPreference,
629+
hint,
630+
caseInsensitive,
631+
explain,
632+
}: QueryOptions
624633
): Promise<any> {
625634
schema = convertParseSchemaToMongoSchema(schema);
626635
const mongoWhere = transformWhere(className, query, schema);
@@ -653,6 +662,7 @@ export class MongoStorageAdapter implements StorageAdapter {
653662
maxTimeMS: this._maxTimeMS,
654663
readPreference,
655664
hint,
665+
caseInsensitive,
656666
explain,
657667
})
658668
)
@@ -667,6 +677,47 @@ export class MongoStorageAdapter implements StorageAdapter {
667677
.catch(err => this.handleError(err));
668678
}
669679

680+
ensureIndex(
681+
className: string,
682+
schema: SchemaType,
683+
fieldNames: string[],
684+
indexName: ?string,
685+
caseInsensitive: boolean = false
686+
): Promise<any> {
687+
schema = convertParseSchemaToMongoSchema(schema);
688+
const indexCreationRequest = {};
689+
const mongoFieldNames = fieldNames.map(fieldName =>
690+
transformKey(className, fieldName, schema)
691+
);
692+
mongoFieldNames.forEach(fieldName => {
693+
indexCreationRequest[fieldName] = 1;
694+
});
695+
696+
const defaultOptions: Object = { background: true, sparse: true };
697+
const indexNameOptions: Object = indexName ? { name: indexName } : {};
698+
const caseInsensitiveOptions: Object = caseInsensitive
699+
? { collation: MongoCollection.caseInsensitiveCollation() }
700+
: {};
701+
const indexOptions: Object = {
702+
...defaultOptions,
703+
...caseInsensitiveOptions,
704+
...indexNameOptions,
705+
};
706+
707+
return this._adaptiveCollection(className)
708+
.then(
709+
collection =>
710+
new Promise((resolve, reject) =>
711+
collection._mongoCollection.createIndex(
712+
indexCreationRequest,
713+
indexOptions,
714+
error => (error ? reject(error) : resolve())
715+
)
716+
)
717+
)
718+
.catch(err => this.handleError(err));
719+
}
720+
670721
// Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't
671722
// currently know which fields are nullable and which aren't, we ignore that criteria.
672723
// As such, we shouldn't expose this function to users of parse until we have an out-of-band

0 commit comments

Comments
 (0)