56

The Problem

I have seen this question several times (also in the context of the Firebase Real-Time Database), but I haven't seen a convincing answer to it. The problem statement is fairly simple:

How can (authenticated) users choose a username that hasn't been taken yet?

First of all, the why: After a user authenticates, they have a unique user ID. Many web-apps, however, let the user choose a "display name" (how the user wants to appear on the website), in order to protect the users personal data (like real name).

The Users Collection

Given a data structure like the following it is possible to store a username along with other data for each user:

/users  (collection)
    /{uid}  (document)
        - name: "<the username>"
        - foo: "<other data>"

However, nothing prevents another user (with a different {uid}) to store the same name in their record. As far as I know, there is no "security rule" that allows us to check if the name has already been by another user.

Note: A client side check is possible, but unsafe as a malicious client could omit the check.

The Reverse Mapping

Popular solutions are creating a collection with a reverse mapping:

/usernames  (collection)
    /{name}  (document)
       - uid: "<the auth {uid} field>"

Given this reverse mapping, it is possible to write a security rule to enforce that a username is not already taken:

match /users/{userId} {
  allow read: if true;
  allow create, update: if
      request.auth.uid == userId &&
      request.resource.data.name is string &&
      request.resource.data.name.size() >= 3 &&
      get(/PATH/usernames/$(request.resource.data.name)).data.uid == userId;
}

and to force a user to create a usernames document first:

match /usernames/{name} {
  allow read: if true;
  allow create: if
      request.resource.data.size() == 1 &&
      request.resource.data.uid is string &&
      request.resource.data.uid == request.auth.uid;
}

I believe the solution is half-way there. However, there are still a few unsolved issues.

Remaining Issues / Questions

This implementation is quite involved already but it doesn't even solve the problem of users that want to change their user name (requires record deletion or update rules, etc.)

Another issue is, nothing prevents a user from adding multiple records in the usernames collection, effectively snatching all good usernames to sabotage the system.

So to the questions:

  • Is there a simpler solution to enforce unique usernames?
  • How can spamming the usernames collection be prevented?
  • How can the username checks be made case-insensitive?

I tried also enforcing existence of the users, with another exists() rule for the /usernames collection and then committing a batch write operation, however, this doesn't seem to work ("Missing or insufficient permissions" error).

Another note: I have seen solutions with client-side checks. BUT THESE ARE UNSAFE. Any malicious client can modify the code, and omit checks.

crazypeter
  • 1,279
  • 1
  • 10
  • 14
  • 1
    Somebody working on Firestore twittert and I believe he suggested that it is not necessary to create the reverse lookup: https://twitter.com/abeisgreat/status/920730970751254528 – crazypeter Nov 21 '17 at 05:38
  • Did you come up with a solid solution to this? – gbhall Mar 19 '18 at 18:29
  • 1
    I am basically using the solution above, I implemented the rules to support updating user names, but malicious spamming and case-insensitive matching is still an open issue. – crazypeter Mar 20 '18 at 01:17
  • how about moving the client side check to functions? let the user send a 'change username request' to functions, and do the check there. If succesfull set the user's username from functions which should be read-only. – Linxy Mar 20 '18 at 20:41
  • I have not worked with functions before, but it sounds ideal. Is such a thing feasible with functions? – crazypeter Mar 21 '18 at 16:58
  • 2
    I'm pretty sure that is one of the main usecases for functions. Offload intensive data processing from the device (like thumbnail generation), execute sensitive operations (e.g enforcing unique usernames) in a secure environment and sending push notifications/emails (ofc. there are plenty more). – Linxy Mar 21 '18 at 20:55
  • I believe this is possible using a combination of two collections, Firestore rules and batched writes. See my answer here for how this can be done https://stackoverflow.com/questions/47543251/firestore-unique-index-or-unique-constraint – Brian Neisler Jan 24 '20 at 07:37
  • with the functions being async, how should we listen for the answer as to if a username is available or not from the client? – Miguel Stevens Dec 05 '21 at 18:49

9 Answers9

28

@asciimike on twitter is a firebase security rules developer. He says there is currently no way to enforce uniqueness on a key on a document. https://twitter.com/asciimike/status/937032291511025664

Since firestore is based on Google Cloud datastore it inherits this issue. It's been a long standing request since 2008. https://issuetracker.google.com/issues/35875869#c14

However, you can achieve your goal by using firebase functions and some strict security rules.

You can view my entire proposed solution on medium. https://medium.com/@jqualls/firebase-firestore-unique-constraints-d0673b7a4952

jqualls
  • 1,483
  • 14
  • 19
  • 1
    This is an awesome workaround for webapps, but how can one enforce this on native apps with the new firestore? – Mr.Drew Jan 26 '19 at 16:09
  • 1
    Any updates on this? No Firestore built in solution yet? – Andre Cytryn Apr 10 '19 at 04:22
  • @Mr.Drew did you find a solution for this? – Ahsan Jan 28 '20 at 07:16
  • 1
    @Ahsan, not really, but there are a few strategies for different workarounds. One that comes to mind is creating a separate collection of current usernames to check every time you want to add or change a username. This requires more client side code or firebase functions that check the username collection though. – Mr.Drew Mar 14 '20 at 05:54
  • correct me if I am wrong but on your cloud function line 51: if (unameDoc.exists && unameDoc.data.uid !== req.user.uid) I believe you meant === – EmreAkkoc May 26 '20 at 10:13
  • I believe you don't need a firebase function (or custom backend at all), I suggest this: https://stackoverflow.com/a/76745076/2469583 – josue.0 Jul 22 '23 at 17:42
21

Created another, pretty simple solution for me.

I have usernames collection to storing unique values. username is available if the document doesn't exist, so it is easy to check on front-end.

Also, I added the pattern ^([a-z0-9_.]){5,30}$ to valide a key value.

Checking everything with Firestore rules:

function isValidUserName(username){
  return username.matches('^([a-z0-9_.]){5,30}$');
}

function isUserNameAvailable(username){
  return isValidUserName(username) && !exists(/databases/$(database)/documents/usernames/$(username));
}

match /users/{userID} {
  allow update: if request.auth.uid == userID 
      && (request.resource.data.username == resource.data.username
        || isUserNameAvailable(request.resource.data.username)
      );
}

match /usernames/{username} {
  allow get: if isValidUserName(username);
}

Firestore rules will not allow updating user's document in case if the username already exists or have an invalid value.

So, Cloud Functions will be handling only in case if the username has a valid value and doesn't exist yet. So, your server will have much less work.

Everything you need with cloud functions is to update usernames collection:

const functions = require("firebase-functions");
const admin = require("firebase-admin");

admin.initializeApp(functions.config().firebase);

exports.onUserUpdate = functions.firestore
  .document("users/{userID}")
  .onUpdate((change, context) => {
    const { before, after } = change;
    const { userID } = context.params;

    const db = admin.firestore();

    if (before.get("username") !== after.get('username')) {
      const batch = db.batch()

      // delete the old username document from the `usernames` collection
      if (before.get('username')) {
        // new users may not have a username value
        batch.delete(db.collection('usernames')
          .doc(before.get('username')));
      }

      // add a new username document
      batch.set(db.collection('usernames')
        .doc(after.get('username')), { userID });

      return batch.commit();
    }
    return true;
  });
Edric
  • 24,639
  • 13
  • 81
  • 91
Bohdan Didukh
  • 1,276
  • 18
  • 21
  • 1
    After spending hours figuring out how to get this working (I kept getting invalid auth because I wasn't paying attention to your regex rules, also the line `{ userID }` should be a key value pair) I finally got it, although I had to add the lines `admin.initializeApp(functions.config().firebase);` as noted [here in the docs for node.js](https://firebase.google.com/docs/firestore/quickstart) – Mr.Drew Jan 27 '19 at 13:27
  • @Mr.Drew The SDK can also be initialized with no parameters. In this case, the SDK uses Google Application Default Credentials and reads options from the FIREBASE_CONFIG environment variable. [You can read more information here](https://firebase.google.com/docs/admin/setup#initialize_the_sdk) – Bohdan Didukh Jan 28 '19 at 10:47
  • Ah I see that now, thanks. But I wonder why I didn't have to do that for a realtime datatbase functions file, but I did for this firestore one? In case anyone starting off stumbles upon this check [here](https://cloud.google.com/docs/authentication/production) for more info about GOOGLE_APPLICATION_CREDENTIALS in server to server communications. Since it's still in beta I'm trying to get familiar with it and not implementing it in a larger scope project. Thus the default instructions in the first doc link worked for me. – Mr.Drew Jan 29 '19 at 04:28
  • what if the user doesn't have an username yet and I want to change some other field in users/{ userID } – EmreAkkoc May 26 '20 at 05:49
  • also !exists(/databases/{database}/documents/usernames/$(username)) is always returning false – EmreAkkoc May 26 '20 at 07:45
  • 1
    @EmreAkkoc, - security rules allowing to update other fields if the username is the same as it was or valid. - !exists(/databases/{database}/documents/usernames/$(username)) returns false only when the username exists. – Bohdan Didukh Jun 05 '20 at 13:13
  • This method doesn't allow the user to change other fields? Since the username property is hardcoded in the rules? – Miguel Stevens Dec 05 '21 at 18:18
  • @MiguelStevens, these rules will not give us permissions to update other fields only in the case of username value is changed and it is not available to use(chosen by someone else) – Bohdan Didukh Dec 06 '21 at 20:02
  • 1
    I guess one problem with this solution is scenario when user A takes username "john" and before trigger gets fired / completed user B takes the same username :) – Piotr Nov 18 '22 at 10:10
  • @Piotr yes, that's why I propose this: https://stackoverflow.com/a/76745076/2469583 – josue.0 Jul 22 '23 at 17:37
4

Create a series of cloud functions that are triggered whenever a document is added, updated, or deleted in the users table. The cloud functions will maintain a separate lookup table named usernames, with document ids set to the usernames. Your front-end app can then query the usernames collection to see if a username is available.

Here is TypeScript code for the cloud functions:

/* Whenever a user document is added, if it contains a username, add that
   to the usernames collection. */
export const userCreated = functions.firestore
  .document('users/{userId}')
  .onCreate((event) => {

    const data = event.data();
    const username = data.username.toLowerCase().trim();

    if (username !== '') {
      const db = admin.firestore();
      /* just create an empty doc. We don't need any data - just the presence 
         or absence of the document is all we need */
      return db.doc(`/usernames/${username}`).set({});
    } else {
      return true;
    }

  });

  /* Whenever a user document is deleted, if it contained a username, delete 
     that from the usernames collection. */
  export const userDeleted = functions.firestore
    .document('users/{userId}')
    .onDelete((event) => {

      const data = event.data();
      const username = data.username.toLowerCase().trim();

      if (username !== '') {
        const db = admin.firestore();
        return db.doc(`/usernames/${username}`).delete();
      }
      return true;
    });

/* Whenever a user document is modified, if the username changed, set and
   delete documents to change it in the usernames collection.  */
export const userUpdated = functions.firestore
  .document('users/{userId}')
  .onUpdate((event, context) => {

    const oldData = event.before.data();
    const newData = event.after.data();

    if ( oldData.username === newData.username ) {
      // if the username didn't change, we don't need to do anything
      return true;
    }

    const oldUsername = oldData.username.toLowerCase().trim();
    const newUsername = newData.username.toLowerCase().trim();

    const db = admin.firestore();
    const batch = db.batch();

    if ( oldUsername !== '' ) {
      const oldRef = db.collection("usernames").doc(oldUsername);
      batch.delete(oldRef);
    }

    if ( newUsername !== '' ) {
      const newRef = db.collection("usernames").doc(newUsername);
      batch.set(newRef,{});
    }

    return batch.commit();
  });
Derrick Miller
  • 1,860
  • 3
  • 21
  • 37
  • What security rules do you use to prevent tampering from the client? – Miguel Stevens Dec 05 '21 at 18:22
  • 1
    I'd post the entire firestore.rules file but it's too long. Here are the lines related to the usernames collection: // USERNAMES match /usernames/{username} { allow get: if true; // Can only query individual usernames to see if they're available allow list: if false; // Can't get a list of usernames allow create: if false; // Can't modify - that is handled by cloud functions allow update: if false; // Can't modify - that is handled by cloud functions allow delete: if false; // Can't modify - that is handled by cloud functions } – Derrick Miller Dec 06 '21 at 14:01
  • Thanks @Derrick, what rules do you have in place for the /users/{userId} itself? – Miguel Stevens Dec 06 '21 at 14:27
  • 1
    @MiguelStevens // USERS match /users/{userId} { allow get: if true; // needed to allow viewing of user profiles allow list: if true; // Can this one be tightened up? allow create: if request.auth.uid != null && request.resource.data.username == '' && request.resource.data.isAdmin == 0; // don't allow users to escalate their own privs allow update: if request.auth.uid == userId && request.resource.data.isAdmin == 0; allow delete: if false; } – Derrick Miller Dec 14 '21 at 01:26
2

This works for me efficiently whereby username must be unique. I am able to add and edit usernames without duplicates.

NOTE: username must be in lowercase always, this eliminates duplicates caused by case sensitivity.

Create users collection:

/users (collection)

/{uid} (document)
      - name "the username"

Create usernames collection:

/usernames (collection)

/{name} (document)
       - uid "the auth {uid} field"

Then in firestore use the following rules:

match /databases/{database}/documents {
    
match /usernames/{name} {
  allow read,create: if request.auth != null;
  allow update: if 
        request.auth.uid == resource.data.uid;
}

match /users/{userId}{
    allow read: if true;
    allow create, update: if 
      request.auth.uid == userId && 
      request.resource.data.name is string && 
      request.resource.data.name.size() >=3 && 
      get(/databases/$(database)/documents/usernames/$(request.resource.data.name)).data.uid == userId;
    }
    
  }
leightski
  • 1,614
  • 1
  • 11
  • 16
1

I store the usernames in the same collection where each username occupies a unique document ID. That way the username which already exists will not be created in the database.

Andrii Artamonov
  • 622
  • 8
  • 15
  • 1
    Wouldn't this forfeit the right of username transfer? At the very least you would need a more complex checking method to go through all the unique document IDs and check their fields. – Mr.Drew Jan 29 '19 at 15:08
0

One possible solution is to store all usernames in a single document's usernames field and then permit only additions to that document using sets in Rules:

match /users/allUsernames {
  function validateNewUsername() {
    // Variables in functions are allowed.
    let existingUsernames = resource.data.usernames;
    let newUsernames = request.resource.data.usernames;
    let usernameToAdd = newUsernames[newUsernames.size() - 1];
    // Sets are a thing too.
    let noRemovals = existingUsernames.toSet().difference(newUsernames.toSet()).size() == 0;
    let usernameDoesntExistYet = !(usernameToAdd in existingUsernames.toSet());
    let exactlyOneAddition = newUsernames.size() == existingUsernames.size() + 1;
    return noRemovals && usernameDoesntExistYet && exactlyOneAddition;
  }
  allow update: if request.resource.data.keys().hasOnly(['usernames']) && validateNewUsername();
}

If you wanted to make a mapping from username -> uid (for validating other parts of the ruleset) this is possible in a single document too. You can just take the keyset of the document and do the same set operations as above.

Scott Crossen
  • 949
  • 8
  • 7
0

This answer addresses your second concern about adding multiple records in the usernames collection. I'm not sure if this is the best method, but I believe a possible approach to prevent a given user from creating multiple username documents is writing an onCreate cloud function which checks if the user has an existing username document when a new username document is created. If the user does, then the cloud function can delete this document to prevent any malicious username parking.

0

Store the max integer user id used in the database in another collection. Query that collection everytime to find the max user id. You can even store other max ids in this collection. It can look something like this:

MaxIDCollection:
   maxStudentIDDocument={ maxID: 55 } //lets say the max user id in db is 55
   maxCourseIDDocument={ maxID: 77 }

                   

Make sure to update the maxIDs everytime you add a new Student or Course. If in future you add a new Student then by querying this collection you can know "if 55 is max then the new Student should get 56 as id."

zangetsu
  • 1
  • 1
0

You can achieve that by just using the following firestore rules:

    // username uniqueness

    match /username_by_user/{userId} {
      allow read;
      allow create: if isValidUser(userId, true);
      allow delete: if isValidUser(userId, false);
    }

    function isValidUser(userId, isCreate) {
      let isOwner = request.auth.uid == userId;
      let username = isCreate ? request.resource.data.username : resource.data.username;
      let createdValidUsername = isCreate ? (getAfter(/databases/$(database)/documents/username/$(username)).data.uid == userId)
      : !existsAfter(/databases/$(database)/documents/username/$(username))
      ;

      return isOwner && createdValidUsername;
    }

    match /username/{username} {
      allow read;
      allow create: if isValidUsername(username, true);
      allow delete: if isValidUsername(username, false);
    }

    function isValidUsername(username, isCreate) {
      let isOwner = request.auth.uid == (isCreate ? request.resource.data.uid : resource.data.uid);
      // only allow usernames that are at least 3 characters long and only contain lowercase letters, numbers, underscores and dots
      let isValidLength = isCreate ? username.matches("^[a-z0-9_.]{3,}$") : true;
      let isValidUserDoc = isCreate ? getAfter(/databases/$(database)/documents/username_by_user/$(request.auth.uid)).data.username == username : 
        !existsAfter(/databases/$(database)/documents/username_by_user/$(request.auth.uid));

      return isOwner && isValidLength && isValidUserDoc;     
    }

Details about why it works and more about how to use it: https://mirror.xyz/dashboard/edit/HYXisWWgweZrILDR6C_8FqAZxcKMMX7qbg3jzRJ1JRU

josue.0
  • 775
  • 1
  • 10
  • 23