Firestore security rules - Unique usernames [duplicate] - google-cloud-firestore

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.

#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

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;
});

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();
});

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;
}
}

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.

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.

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.

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."

Related

How to lock updating/deleting a collection created by a user to the same user who created the collection in firestore (without using cloud Functions)?

I have the following model and rules... the first part of the equation is working fine a user can CRUD his own collection that he created, and it has the the same id as the creator.
the issue lies in the other collection that created by that user since it has a different id (an autogenerated id by firestore and I want to lock this collection to by modified only by the user that created it) any idea how to do this ?
Collections Model
// Data // doc id === uid
subDataId: null,
createdAt: Timestamp.now(),
// Sub Data // doc id === generated automatically by fb refrenced in the data collection.
online: false,
createdAt: Timestamp.now(),
// Firebase rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /Data/{userId} {
allow read , create: if request.auth != null
allow update, delete : if request.auth.uid == userId
}
match /SubData/{SubDataId} {
allow read , create: if request.auth != null
// allow update : if request.auth.uid == ?????????
}
}
}
Write a rule that requires the user to provide their own user ID as a field of the document being created (see the documentation about requiring that a field is provided). Make sure the code in your app does exactly that. Also write a rule that checks if the user id of the user updating the document also matches the field that was previously written by that user.
You might want to read this other question as well.

How do I achieve this security logic in Firebase FireStore?

The illustration below shows the logic of security rules that I need to apply for my firebase firestore.
Description
My app allows user authentication, and I manually activate client accounts. Upon activation, they can add users to their database collection, as well as perform operations on their database. I have illustrated each client as 'Organization' below, and each has multiple users which should only be able to access specific parts of the database collections/documents.
Each organization has an admin user who has full access over the database collection of that particular organization. Each user (from each organization) can access the node 'Elements', as well as their own UID-generated docs and collections only.
It seems like I need custom claim auths to achieve that. I wonder if some alternatives exist, like adding some specific security rules in my fireStore to make this work, or any other alternatives besides the firebase admin sdk tools, as it's time consuming and I'm not an expert backend developer.
Other Details
I use flutter. My app allows clients to authenticate and create their database collection. Clients add users (as team members) with different roles (which affect what collection/document they can access)
This security rules logic is the main thing I'm stuck on right now.
I highly appreciate suggestions and examples that might shed light on my way of achieving that.
illustration
My FireStore security rules right now
One possible solution:
Each organisation should contain a list of strings (userIds), and only users with userId in this list can access the Organisation collection and docs.
Database structure:
organisation_1:
userIds (field containing list of user ids - []<String>):
adminId (field containing admin id - String):
admin (collection):
users (collection):
elements (collection):
premium (collection):
organisation_2:
Security rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isLoggedIn() {
// only true if user is logged in
return request.auth != null;
}
match /organisation/{organisationId} {
function prefix() {
return /databases/$(database)/documents/organisation/$(organisationId);
}
function isAdmin() {
// only true if admin
return isLoggedIn() && request.auth.uid == get(/$(prefix())).data.adminId;
}
function isUser() {
// only true if user
return isLoggedIn() && request.auth.uid in get(/$(prefix())).data.usersId;
}
function isDataOwner(dataId) {
// only true if user is admin or userId is the document id.
// this rule should allow each user access to their own UID-
// generated docs and collections only
return isLoggedIn() && (isAdmin() || dataId == request.auth.uid);
}
// since userIds list is organisation data, we should prevent any
// user from editing it (or only allow admin to edit it).
// if you are using cloud function to update userIds list, set this
// to false. Cloud function does not need access.
allow write: if isAdmin();
allow read: if true;
match /Elements/{elementsId=**} {
// allow access to the entire Elements collection and
// subcollections if isAdmin or isUser.
allow read, write: if isAdmin() || isUser();
}
match /settings/{userId} {
// allow access only if document id is your userId
allow read, write: if isDataOwner(userId);
}
match /adminDocs/{docId} {
// only allow admin
allow read, write: if isAdmin();
}
}
}
}
Then you can use a cloud function to keep your userIds list up to date. Example:
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const db = admin.firestore();
exports.onCreate = functions.firestore
.document("/organisation/{organisationId}/users/{userId}")
.onCreate((_, context) => {
const params = context.params;
const organisationId = params.organisationId;
const userId = params.userId;
const data = {
userIds: admin.firestore.FieldValue.arrayUnion(userId),
};
return db.doc(`/organisation/${organisationId}`)
.set(data, { merge: true });
});
exports.onDelete = functions.firestore
.document("/organisation/{organisationId}/users/{userId}")
onDelete((_, context) => {
const params = context.params;
const organisationId = params.organisationId;
const userId = params.userId;
const data = {
userIds: admin.firestore.FieldValue.arrayRemove(userId),
};
return db.doc(`/organisation/${organisationId}`)
.set(data, { merge: true });
});
You can avoid this cloud function by simply adding userid to userId list when admin creates new user. But cloud function is cleaner (Use it).
UPDATE
$(database) is the name of your firestore database.
{database} (line 3 in my security rules) tells rules to save the actual name of database into database variable.
prefix() returns the path to the organisation document.
If a user tries to access his document in this path organisation/12345/users/67890, then $(database) is default and prefix() returns /databases/default/documents/organisation/12345/
You can go to firestore docs to see how $(database) and path (prefix()) is being used.

How write Firestore rule to match email field in document

I have a field labelled 'email' in my 'myCollection' documents and I'm trying to write a firestore rule to permit deletion when the email record of a logged-in user matches the email in the record. I've coded my rule as follows:
match /myCollection/{email} {
allow delete : if request.auth != null && request.auth.token.email == email;
}
This fails to permit the deletion of matching records. Advice would be appreciated because although this question has been asked numerous times on StackOverflow, the answers I've seen are contradictory and none of them works for me.
I'm testing this in "playground" and if I set 'location' to myCollection/{documents = **}, the rule fails. See screenshots below:
The deletion code in my program reads:
const myCollectionCol = collection(db, 'myCollection');
const myCollectionQuery = query(myCollectionCol, where("email", "==", email));
const myCollectionSnapshot = await getDocs(myCollectionQuery);
myCollectionSnapshot.forEach((doc) => {
deleteDoc(doc.ref);
});
You're firing a query that returns all documents that have a field email with a value equal to the email variable:
query(myCollectionCol, where("email", "==", email))
But your rules only allow deleting the document that has its *document ID equal to the user's email property in their auth token.
So to delete the only document you're allowed to delete:
import { getAuth } from "firebase/auth";
const auth = getAuth();
const user = auth.currentUser;
const myEmailAddress = user.email;
...
const myCollectionCol = collection(db, 'myCollection');
deleteDoc(doc(myCollectionCol, myEmailAddress));
If you want a user to be able to delete all documents where email has their email address, that'd be:
match /myCollection/{doc} {
allow delete : if request.auth != null &&
request.auth.token.email == resource.data.email;
// 👆 the email field in the doc
}

Get role in every rule Firebase security

Hello so I have a role in my user collection and I wanted to write the rules depending on the role so if the role is the teacher you can have access to a little more stuff than the parent role. Now my question is there a possibility that I can access the role and use it for every collection, not only the user collection. Like a function that just checks every time what your role is?
I'm doing this for the first time and I'm not pretty sure if I understand everything right, so far.
This is what I have in my rules so far:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isOneOfRoles(rsc, array) {
return isSignedIn() && ((getRole() in array) || rsc.data.openWorld == true);
}
function getRole() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'pädagoge';
}
match /posts/{userPosts} {
allow read: if isSignedIn();
allow create: if isOneOfRoles(resource, ['pädagoge']);
}
match /messages/{messages} {
allow read, write: if isSignedIn();
}
}
}
UPDATE
I've tried your security rules in the Firestore "Rules playground". You can see below that you need to do isOneOfRoles(request.resource, ['pädagoge']);: with only resource, the rule engine cannot check the value of the field openWorld beacause the future state of the document is contained in the request.resource variable, not in the resource one. See the doc for more details.
You also need to have a corresponding user in the users collection with a role field with the value pädagoge: in my example the user's UID is A1 (i.e. the ID of the Firestore doc in the users collection). See on the second and third screenshots below how we use this value in the Firebase UID field in the "Rules playground" simulator.
(same screenshot as above, only the left pane was scrolled down to show the Firebase UID field)

Having problems with Firebase access rules

This is mostly me playing with various cloud storage mechanisms, so I came with some test code. In this one, I wanted to have users and group them into households. The data structures I have in Firestore are:
Users/{user}/
name (string)
email (string)
admin (bool)
Households/{household}/
name (string)
users (array of string)
The identifier for {user} is the user ID from the User api (I'm using Swift for my code); the identifier for {household} is a UUID.
The rules I have for the database are:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /Users/{uid} {
allow create: if request.auth.uid != null;
allow read, write: if request.auth.uid != null && (request.auth.uid == uid || isAdmin());
allow delete: if isAdmin();
}
match /Households/{household} {
allow create: if request.auth.uid != null;
allow read, write: if hasAccess(household);
}
function hasAccess(household) {
let id = (request.auth != null) ? string(request.auth.uid) : "";
let users = id == "" ? [] : get(/databases/$(database)/documents/Households/$(household)).data.users;
return id != null && ((id in users) || isAdmin());
}
function isAdmin() {
let id = request.auth.uid;
return get(/databases/$(database)/documents/Users/$(id)).data.admin == true;
}
}
}
The Playground works with my UID; however, my code does not -- it gets an access denied error. (If I set my UID to have admin set to true, it works, so I know that part of the rules is working.)
A different problem on stackoverflow I found yesterday (63621376) showed the same problem, and it was fixed by converting a value to a string, which you can see I try there.
I have been unable to get the CLI emulator working, primarily because I use Macs, and I haven't been able to get the 1.8 version of Java installed in a way that it can work with.
ETA the client code:
let ref = self.dbHouseholds!
ref
.whereField("users", arrayContains: self.user?.id ?? "")
.getDocuments { snapshots, err in
print("snapshots = \(snapshots), err = \(err)")
}
It also fails if I don't have the .whereField query. The errors are
snapshots = nil, err = Optional(Error Domain=FIRFirestoreErrorDomain Code=7 "Missing or insufficient permissions." UserInfo={NSLocalizedDescription=Missing or insufficient permissions.})
The rule is denying your query because Firebase security rules are not filters. Please be sure to read and understand that documentation thoroughly.
The playground allows you to perform a request for a single document, but what you're showing here is a collection query, which you can't simulate in the console. When you perform a collection query, the rules will reject any query where there is any possible document that might not allow access. Rules will not scan every single document to pick out the ones that match - that does not scale at all.
Your function hasAccess depends on the value of a variable "household" containing an individual document ID being accessed. Since you are querying for many documents, you can't use that variable to check each document.
If you want to write a rule that requires that users can only query documents that have their UID in the users field, you'll have to write that condition like this instead:
request.auth.uid in resource.data.users
This will enforce the where clause in your query.