├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets └── preview.png ├── nest-cli.json ├── nodemon-debug.json ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.module.ts │ ├── auth.resolvers.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── auth.types.graphql │ ├── guards │ │ ├── admin.guard.ts │ │ ├── jwt-auth.guard.ts │ │ ├── username-email-admin.guard.ts │ │ └── username-email.guard.ts │ ├── interfaces │ │ └── jwt-payload.interface.ts │ └── strategies │ │ └── jwt.strategy.ts ├── config │ ├── config.module.ts │ ├── config.service.spec.ts │ └── config.service.ts ├── decorators │ └── admin-allowed-args.ts ├── graphql.classes.ts ├── main.ts ├── scalars │ ├── date.scalar.ts │ └── object-id.scalar.ts └── users │ ├── schemas │ └── user.schema.ts │ ├── users.module.ts │ ├── users.resolvers.ts │ ├── users.service.spec.ts │ ├── users.service.ts │ └── users.types.graphql ├── test ├── jest-e2e.json └── users.e2e-spec.ts ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.tmp 8 | *.tmp.* 9 | log.txt 10 | *.sublime-project 11 | *.sublime-workspace 12 | .vscode/ 13 | npm-debug.log* 14 | *.env 15 | 16 | .idea/ 17 | .ionic/ 18 | .sourcemaps/ 19 | .sass-cache/ 20 | .tmp/ 21 | .versions/ 22 | coverage/ 23 | www/ 24 | node_modules/ 25 | tmp/ 26 | temp/ 27 | dist/ 28 | platforms/ 29 | plugins/ 30 | plugins/android.json 31 | plugins/ios.json 32 | $RECYCLE.BIN/ 33 | secrets.ts 34 | 35 | .DS_Store 36 | Thumbs.db 37 | UserInterfaceState.xcuserstate 38 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Eric Kitaif 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nest-user-auth 2 | 3 | ![last commit](https://img.shields.io/github/last-commit/EricKit/nest-user-auth.svg) ![repo size](https://img.shields.io/github/repo-size/EricKit/nest-user-auth.svg) ![open issues](https://img.shields.io/github/issues-raw/EricKit/nest-user-auth.svg) ![liscense](https://img.shields.io/github/license/erickit/nest-user-auth.svg) 4 | 5 | If this project helps you, please add a star! If you see an issue, please post it! 6 | 7 | This project uses NestJS, GraphQL, and MongoDB. 8 | 9 | This project implements user authentication. It will be easy to add other GraphQL schemas following the same structure. User auth is implemented in this project because it is one of the hardest and most common things to create for an API. 10 | 11 | The intent of this project is to provide an example of how to integrate all of these technologies together that are in the NestJS documentation (NestJS, GraphQL, MongoDB, Mongoose, Passport, JWT, DotEnv, Joi, Jest) into a working backend. If you recognize an anti-pattern or a better way to do something, please post an issue. 12 | 13 | ![preview](assets/preview.png) 14 | 15 | ## Getting Started 16 | 17 | Ensure a MongoDB server is running locally. 18 | 19 | ### Create a development.env file 20 | 21 | Add a `development.env` file to the root of your project. 22 | 23 | ```env 24 | MONGO_URI=mongodb://localhost:27017/user-auth 25 | JWT_SECRET=someSecret 26 | EMAIL_ENABLED=true 27 | EMAIL_SERVICE=Mailgun 28 | EMAIL_USERNAME=email@mailgun.com 29 | EMAIL_PASSWORD=emailSMTPpassword 30 | EMAIL_FROM=from@somedomain.com 31 | ``` 32 | 33 | #### Required Parameters 34 | 35 | `MONGO_URI` the location of your mongo server and database name you want 36 | 37 | `JWT_SECRET` a secret string used to make the keys. Create a random string. 38 | 39 | #### Optional Parameters 40 | 41 | `MONGO_AUTH_ENABLED` set to `true` if your database requires a username and password. If `true`, the user specified by `MONGO_USER` must exist on the database specified in the `MONGO_URI` option. If `true`, `MONGO_USER` and `MONGO_PASSWORD` are required. 42 | 43 | `MONGO_USER`, `MONGO_PASSWORD` the user and password for authentication. Recommend a role with `readWrite`. 44 | 45 | `JWT_EXPIRES_IN` Seconds until token expires. If not set, there will be no expiration. 46 | 47 | `EMAIL_ENABLED` If email services should be used, `EMAIL_*` fields are required if enabled. 48 | 49 | `EMAIL_SERVICE` Nodemailer "Well Known Service" https://nodemailer.com/smtp/well-known/ 50 | 51 | `EMAIL_USERNAME`, `EMAIL_PASSWORD` Information for the SMTP service. On Mailgun it is the credentials under Domains -> SMTP Credentials. Use the SMTP service, not the API. 52 | 53 | `EMAIL_FROM` The email address the program will use as the from address. 54 | 55 | `TEST_EMAIL_TO` When running tests, where emails will be sent. This should be a real email address you own to verify emails are getting out. 56 | 57 | ### Start the server 58 | 59 | `npm install` 60 | 61 | `npm run start` 62 | 63 | That's it, the graphQL playground is found at `http://localhost:3000/graphql` 64 | 65 | ## Model Management 66 | 67 | It is challenging not to repeat the structure of the models in the GraphQL schema, Mongo schema, and Typescript interfaces. The goal is to have one truth point for the models and extend that data when more data is needed. 68 | 69 | With NestJS 6.0.0 a **code first** approach was introduced. This project uses the **schema first** approach to be language agnostic. The starting point for models is the `*.types.graphql` files. They contain the GraphQL schema and have properties that every model, at a minimum, should have. 70 | 71 | `@nestjs/graphql` creates a `graphql.classes.ts` file to match the GraphQL schema when the program is started. These classes are used as the base class for the Mongoose Schema and in place of DTOs. Of note, the `IMutation` and `IQuery` classes created by `@nestjs/graphql` are not extended by the resolver class, though it would be nice if they were. It doesn't appear possible without modification of the `grahql.classes.ts` file because all the methods aren't implemented in the same resolver. 72 | 73 | `username` is the primary field to identify a user in a request. Initially `username` or `email` were accepted, but for simplicity the schema moved to only username. Both username and email fields are in the JWT data, and because they are both unique, either could be used. 74 | 75 | The database stores a unique lowercase value for both username and email. This is to lookup the user's username or email without case being a factor. Lowercase username and email are also unique, therefore user@Email.com and user@email.com can't both register. The normal cased version is used for everything except lookup. GraphQL Schemas are not aware lowercase values exist intentionally. 76 | 77 | The database handles creating the lowercase values with hooks for `save` and `findOneAndUpdate`. If another method is used to update or save a User, ensure a hook is created to create the lowercase values. 78 | 79 | ## Users 80 | 81 | Add a user via the graphql playground or a frontend. See example mutations and queries below. 82 | 83 | Update that user's Document to have the string `admin` in the permissions array. Only an admin can add another admin, so the first user must be done manually. MongoDB Compass is a great tool to modify fields. That user can now add the admin permission or remove the admin permission to or from other users. 84 | 85 | The `UsersService` `update` method will update any fields which are valid and not duplicates, even if other fields are invalid or duplicates. 86 | 87 | Users can change their `username`, `password`, `email`, or `enabled` status via a mutation. Changing their username will make their token unusable (it won't authenticate when the user presenting the token's username is checked against the token's username). This may or may not be the desired behavior. If using on a front end, make it obvious that if the user changes their username, it'll log the user out (or the front end must get a new token via logging in behind the scenes - but this would likely require storing the password and is not recommended). 88 | 89 | If a user sets `enabled` to `false` on their account, they cannot log back in (because it is disabled), only an admin can change it back. 90 | 91 | Because both unique properties `username` and `email` can be changed, `_id` should be used as keys for relationships. 92 | 93 | See `test/users.e2e-spec.ts` for expected results to mutations and queries. 94 | 95 | ## Environments 96 | 97 | Add a `test.env` file which contains a different `MONGO_URI` than `development.env`. See the testing section for details. 98 | 99 | Add any other environments for production and test. The environment variable `NODE_ENV` is used to determine the correct environment to work in. The program defaults to `development` if there is not a `NODE_ENV` environment variable set. For example, if the configuration is stored in `someEnv.env` file in production then set the `NODE_ENV` environment variable to `someEnv`. This can be done through `package.json` scripts, local environment variables, or your `launch.json` configuration in VS Code. If you do nothing, it will look for `development.env`. Do not commit this file. 100 | 101 | ## Authentication 102 | 103 | Add the token to your headers `{"Authorization": "Bearer eyj2aGc..."}` to be authenticated via the `JwtAuthGuard`. 104 | 105 | If a user's account property `enabled` is set to false, their token will no longer authenticate. Many critiques of JWTs vs. session based authentication solutions are that a JWT cannot be invalidated once issued. While that is true, no request will authenticate with a valid JWT while the account associated with the token's `enabled` field is false. An admin or the user can set that field via an update. 106 | 107 | Admin must be set manually as a string in permissions for the first user (add `admin` to the permissions array). That person can then add admin to other users via a mutation. Permissions is an array of strings so that other permissions can be added to allow custom guards. 108 | 109 | Users can modify or view their own data. Admins can do anything except refresh another user's token or change their password, which would allow the admin to impersonate that user. 110 | 111 | The `UsernameEmailGuard` compares the user's email or username with the same field in a query. If any query or mutation in the resolver has `doAnythingWithUser(username: string)` or `doAnythingWithUser(email: string)` and that email / username matches the user which is requesting the action, it will be approved. Username and email are unique, and the user has already been verified via JWT. **If there is not a username or email in the request, it will pass.** This is because the resolvers will set the action on the user making the request. For example, on `updateUser` if no username is specified, the modification is on the user making the request. 112 | 113 | The `UsernameEmailAdminGuard` is the same as the `UsernameEmailGuard` except it also allows admins. Admins should not be allowed to change everything. For example, an admin should not be allowed to set another user's password. This would allow the admin to impersonate that user. The `@AdminAllowedArgs` decorator has been added for this reason to this guard. If this decorator is used, only the arguments specified are allowed. Placing the below decorator above the `updateUser` resolver will not allow an admin to specify the `fieldsToUpdate.password` argument. 114 | 115 | ```Typescript 116 | @AdminAllowedArgs( 117 | 'username', 118 | 'fieldsToUpdate.username', 119 | 'fieldsToUpdate.email', 120 | 'fieldsToUpdate.enabled', 121 | ) 122 | ``` 123 | 124 | The `AdminGuard` only allows admins. 125 | 126 | The `JwtAuthGuard` ensures that there is a valid JWT and that the user associated with the JWT exists in the database. 127 | 128 | The User's Document is accessable in the resolver via `@Context('req')` should it be needed. For example, a user creates a Purchase and that user's ID needs to be attached to the purchase. An example mutation is shown below. 129 | 130 | ```Typescript 131 | // This is an example of how to get access to the validated user making the request 132 | @UseGuards(JwtAuthGuard) 133 | @Mutation('userInResolver') 134 | userInResolver(@Context('req') request: any) { 135 | const user: UserDocument = request.user; 136 | } 137 | ``` 138 | 139 | ## Relationships 140 | 141 | To add a relationship with the NestJS Schema first approach and Mongoose there are a few caveats. Take for example a one-to-many relationship where a Purchase can be made by one user, but a user can have many purchases. Likely, the Purchase GraphQL schema will look like this: 142 | 143 | ```graphql 144 | type Purchase { 145 | product: String! 146 | customer: User! 147 | ... 148 | } 149 | ``` 150 | 151 | This allows a user to make a query that contains both the purchase and its customer's subfields (see below for security concerns). The Schema first approach will create a file that contains the `Purchase` class, as defined by the schema above, with the `customer` property of type `User`. For the MongoDB Schema and Document, a different field for the foreign key must be created. For example: 152 | 153 | ```typescript 154 | export interface PurchaseDocument extends Purchase, Document { 155 | // Declaring properties that are not in the GraphQL Schema for a Purchase 156 | customerId: Types.ObjectId; 157 | } 158 | 159 | export const PurchaseDocument: Schema = new Schema( 160 | { 161 | ..., 162 | customerId: { 163 | type: Types.ObjectId, 164 | ref: 'User', 165 | }, 166 | }) 167 | ``` 168 | 169 | The `customerId` property of the `PurchaseDocument` interface can reference the `ObjectId` and the `customer` property of the `Purchase` class can reference the `User` class. The `Purchase` class as defined by the schema only has a `customer` property, while the `PurchaseDocument` has both the `customer` and `customerId` properties. This makes sense because a user should never care about how the relationship is built. Below is an example of how the customer's information, including ID, can be queried. 170 | 171 | ```Typescript 172 | @ResolveProperty() 173 | async customer(@Parent() purchase: PurchaseDocument): Promise { 174 | const userDocument = await this.usersService.findOneById(comment.customerId); 175 | return userDocument; 176 | } 177 | ``` 178 | 179 | ```Graphql 180 | query purchase { 181 | purchase(id: "35") { 182 | price 183 | customer { 184 | username 185 | } 186 | } 187 | } 188 | ``` 189 | 190 | Keep in mind, the above example would create a security issue as every field of a `User` would be accessable to anyone querying a Location. To fix this, add a new type to the GraphQL schema such as `SanitizedUser` which contains only public fields. Then, the `Purchase.customer` property would be changed from `User` to `SanitizedUser`. 191 | 192 | It would be nice to have the `customer` property be a union of a `MongoId` and `User`. This would allow Mongoose's `populate` method to be used to replace the `MongoId` with a `User`. However, a property cannot be made more generic when extending a class. 193 | 194 | ## Testing 195 | 196 | To test, ensure that the environment is different than the `development` environment. When the end to end tests run, they will delete all users in the database specified in the environment file on start. Currently running `npm run test:e2e` will set `NODE_ENV` to `test` based on `package.json` scripts. This will default to the `test.env` file. 197 | 198 | Create `test.env` to have a different database than the `development.env` file. To test Nodemailer include the variable `TEST_EMAIL_TO` which is the email that will receive the password reset email. 199 | 200 | ### Example `test.env` 201 | 202 | ```env 203 | MONGO_URI=mongodb://localhost:27017/user-auth-test 204 | JWT_SECRET=someSecret 205 | EMAIL_SERVICE=Mailgun 206 | EMAIL_USERNAME=email@mailgun.com 207 | EMAIL_PASSWORD=emailSMTPpassword 208 | EMAIL_FROM=from@somedomain.com 209 | TEST_EMAIL_TO=realEmailAddress@somedomain.com 210 | ``` 211 | 212 | ## nodemon 213 | 214 | To use nodemon there is a small change required. Because the classes file is built from the schema, it is recreated on each launch. This causes nodemon to restart on a loop. Add `src/graphql.classes.ts` to the `ignore` array in `nodemon.json` to ignore the changes to that file. 215 | 216 | ```typescript 217 | { 218 | "ignore": ["src/**/*.spec.ts", "src/graphql.classes.ts"], 219 | } 220 | ``` 221 | 222 | ## Next tasks 223 | 224 | Add email verification when a user registers. 225 | 226 | ## GraphQL Playground Examples 227 | 228 | ```graphql 229 | query loginQuery($loginUser: LoginUserInput!) { 230 | login(user: $loginUser) { 231 | token 232 | user { 233 | username 234 | email 235 | } 236 | } 237 | } 238 | ``` 239 | 240 | ```json 241 | { 242 | "loginUser": { 243 | "username": "usersname", 244 | "password": "passwordOfUser" 245 | } 246 | } 247 | ``` 248 | 249 | ```graphql 250 | query { 251 | users { 252 | username 253 | email 254 | } 255 | } 256 | ``` 257 | 258 | ```graphql 259 | query user { 260 | user(email: "email@test.com") { 261 | username 262 | } 263 | } 264 | ``` 265 | 266 | ```graphql 267 | query refreshToken { 268 | refreshToken 269 | } 270 | ``` 271 | 272 | ```graphql 273 | mutation updateUser($updateUser: UpdateUserInput!) { 274 | updateUser(username: "usernametoUpdate", fieldsToUpdate: $updateUser) { 275 | username 276 | email 277 | updatedAt 278 | createdAt 279 | } 280 | } 281 | ``` 282 | 283 | ```json 284 | { 285 | "updateUser": { 286 | "username": "newUserName", 287 | "email": "newEmail@test.com", 288 | "enabled": false 289 | } 290 | } 291 | ``` 292 | 293 | ```graphql 294 | mutation CreateUser { 295 | createUser( 296 | createUserInput: { 297 | username: "username" 298 | email: "user@test.com" 299 | password: "userspassword" 300 | } 301 | ) { 302 | username 303 | } 304 | } 305 | ``` 306 | 307 | ```graphql 308 | mutation { 309 | addAdminPermission(username: "someUsername") { 310 | permissions 311 | } 312 | } 313 | ``` 314 | 315 | ```graphql 316 | mutation { 317 | removeAdminPermission(username: "someUsername") { 318 | permissions 319 | } 320 | } 321 | ``` 322 | 323 | ```graphql 324 | query { 325 | forgotPassword(email: "some-email@email.com") 326 | } 327 | ``` 328 | 329 | ```graphql 330 | mutation { 331 | resetPassword( 332 | username: "username" 333 | code: "code-from-the-email" 334 | password: "password" 335 | ) { 336 | username 337 | } 338 | } 339 | ``` 340 | -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EricKit/nest-user-auth/6796c0451b37a536668898468cb40267021dc7c7/assets/preview.png -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-user-auth", 3 | "version": "1.0.3", 4 | "description": "description", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "nodemon", 12 | "start:debug": "nodemon --config nodemon-debug.json", 13 | "prestart:prod": "rimraf dist && npm run build", 14 | "start:prod": "node dist/main.js", 15 | "lint": "tslint -p tsconfig.json -c tslint.json", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "NODE_ENV=test jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^7.0.2", 24 | "@nestjs/core": "^7.0.2", 25 | "@nestjs/graphql": "^6.5.3", 26 | "@nestjs/jwt": "^7.0.0", 27 | "@nestjs/mongoose": "^6.4.0", 28 | "@nestjs/passport": "^7.0.0", 29 | "@nestjs/platform-express": "^7.0.2", 30 | "apollo-server-express": "^2.14.2", 31 | "axios": ">=0.21.1", 32 | "bcrypt": "^4.0.1", 33 | "dotenv": "^8.2.0", 34 | "graphql": "^14.6.0", 35 | "graphql-tools": "^4.0.6", 36 | "joi": "^14.3.1", 37 | "mongoose": "^5.9.5", 38 | "nodemailer": "^6.4.5", 39 | "npm": "^6.14.6", 40 | "passport": "^0.4.1", 41 | "passport-jwt": "^4.0.0", 42 | "reflect-metadata": "^0.1.13", 43 | "rimraf": "^3.0.2", 44 | "rxjs": "^6.5.4", 45 | "typescript": "^3.8.3" 46 | }, 47 | "devDependencies": { 48 | "@nestjs/testing": "^6.11.11", 49 | "@types/bcrypt": "^3.0.0", 50 | "@types/express": "^4.17.3", 51 | "@types/graphql": "^14.5.0", 52 | "@types/jest": "^24.9.1", 53 | "@types/joi": "^14.3.4", 54 | "@types/mongodb": "^3.5.2", 55 | "@types/mongoose": "^5.7.6", 56 | "@types/node": "^12.12.30", 57 | "@types/nodemailer": "^6.4.0", 58 | "@types/passport-jwt": "^3.0.3", 59 | "@types/supertest": "^2.0.8", 60 | "jest": "^25.1.0", 61 | "nodemon": "^2.0.2", 62 | "prettier": "^1.19.1", 63 | "supertest": "^4.0.2", 64 | "ts-jest": "^24.3.0", 65 | "ts-loader": "^6.2.1", 66 | "ts-node": "^8.6.2", 67 | "tsconfig-paths": "^3.9.0", 68 | "tslint": "^6.1.0" 69 | }, 70 | "jest": { 71 | "moduleFileExtensions": [ 72 | "js", 73 | "json", 74 | "ts" 75 | ], 76 | "rootDir": "src", 77 | "testRegex": ".spec.ts$", 78 | "transform": { 79 | "^.+\\.(t|j)s$": "ts-jest" 80 | }, 81 | "coverageDirectory": "../coverage", 82 | "testEnvironment": "node", 83 | "testEnvironmentOptions": { 84 | "NODE_ENV": "test" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(appController).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | } 8 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GraphQLModule } from '@nestjs/graphql'; 3 | import { MongooseModule, MongooseModuleOptions } from '@nestjs/mongoose'; 4 | import { UsersModule } from './users/users.module'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import { join } from 'path'; 7 | import { ConfigModule } from './config/config.module'; 8 | import { ConfigService } from './config/config.service'; 9 | 10 | @Module({ 11 | imports: [ 12 | MongooseModule.forRootAsync({ 13 | imports: [ConfigModule], 14 | useFactory: async (configService: ConfigService) => { 15 | const options: MongooseModuleOptions = { 16 | uri: configService.mongoUri, 17 | useNewUrlParser: true, 18 | useCreateIndex: true, 19 | useFindAndModify: false, 20 | useUnifiedTopology: true, 21 | }; 22 | 23 | if (configService.mongoAuthEnabled) { 24 | options.user = configService.mongoUser; 25 | options.pass = configService.mongoPassword; 26 | } 27 | 28 | return options; 29 | }, 30 | inject: [ConfigService], 31 | }), 32 | GraphQLModule.forRoot({ 33 | typePaths: ['./**/*.graphql'], 34 | installSubscriptionHandlers: true, 35 | context: ({ req }: any) => ({ req }), 36 | definitions: { 37 | path: join(process.cwd(), 'src/graphql.classes.ts'), 38 | outputAs: 'class', 39 | }, 40 | }), 41 | UsersModule, 42 | AuthModule, 43 | ConfigModule, 44 | ], 45 | }) 46 | export class AppModule {} 47 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from './config/config.service'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | 5 | @Injectable() 6 | export class AppService {} 7 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; 5 | import { UsersModule } from '../users/users.module'; 6 | import { JwtStrategy } from './strategies/jwt.strategy'; 7 | import { AuthResolver } from './auth.resolvers'; 8 | import { ConfigService } from '../config/config.service'; 9 | import { ConfigModule } from '../config/config.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | PassportModule.register({ defaultStrategy: 'jwt', session: false }), 14 | JwtModule.registerAsync({ 15 | imports: [ConfigModule], 16 | useFactory: (configService: ConfigService) => { 17 | const options: JwtModuleOptions = { 18 | secret: configService.jwtSecret, 19 | }; 20 | if (configService.jwtExpiresIn) { 21 | options.signOptions = { 22 | expiresIn: configService.jwtExpiresIn, 23 | }; 24 | } 25 | return options; 26 | }, 27 | inject: [ConfigService], 28 | }), 29 | forwardRef(() => UsersModule), 30 | ConfigModule, 31 | ], 32 | controllers: [], 33 | providers: [AuthService, AuthResolver, JwtStrategy], 34 | exports: [AuthService], 35 | }) 36 | export class AuthModule {} 37 | -------------------------------------------------------------------------------- /src/auth/auth.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Args, Query, Context } from '@nestjs/graphql'; 2 | import { LoginUserInput, LoginResult } from '../graphql.classes'; 3 | import { AuthService } from './auth.service'; 4 | import { AuthenticationError } from 'apollo-server-core'; 5 | import { JwtAuthGuard } from './guards/jwt-auth.guard'; 6 | import { UseGuards } from '@nestjs/common'; 7 | import { UserDocument } from '../users/schemas/user.schema'; 8 | 9 | @Resolver('Auth') 10 | export class AuthResolver { 11 | constructor(private authService: AuthService) {} 12 | 13 | @Query('login') 14 | async login(@Args('user') user: LoginUserInput): Promise { 15 | const result = await this.authService.validateUserByPassword(user); 16 | if (result) return result; 17 | throw new AuthenticationError( 18 | 'Could not log-in with the provided credentials', 19 | ); 20 | } 21 | 22 | // There is no username guard here because if the person has the token, they can be any user 23 | @Query('refreshToken') 24 | @UseGuards(JwtAuthGuard) 25 | async refreshToken(@Context('req') request: any): Promise { 26 | const user: UserDocument = request.user; 27 | if (!user) 28 | throw new AuthenticationError( 29 | 'Could not log-in with the provided credentials', 30 | ); 31 | const result = await this.authService.createJwt(user); 32 | if (result) return result.token; 33 | throw new AuthenticationError( 34 | 'Could not log-in with the provided credentials', 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | import { getModelToken } from '@nestjs/mongoose'; 4 | import { UserModel } from '../users/schemas/user.schema'; 5 | import { UsersService } from '../users/users.service'; 6 | import { JwtModule } from '@nestjs/jwt'; 7 | import { ConfigService } from '../config/config.service'; 8 | import { ConfigModule } from '../config/config.module'; 9 | 10 | describe('AuthService', () => { 11 | let service: AuthService; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | imports: [ 16 | JwtModule.registerAsync({ 17 | imports: [ConfigModule], 18 | useFactory: async (configService: ConfigService) => ({ 19 | secret: configService.jwtSecret, 20 | signOptions: { 21 | expiresIn: configService.jwtExpiresIn, 22 | }, 23 | }), 24 | inject: [ConfigService], 25 | }), 26 | ], 27 | providers: [ 28 | AuthService, 29 | UsersService, 30 | { 31 | provide: getModelToken('User'), 32 | useValue: UserModel, 33 | }, 34 | ], 35 | }).compile(); 36 | 37 | service = module.get(AuthService); 38 | }); 39 | 40 | it('should be defined', () => { 41 | expect(service).toBeDefined(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, forwardRef, Inject } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { UsersService } from '../users/users.service'; 4 | import { JwtPayload } from './interfaces/jwt-payload.interface'; 5 | import { LoginUserInput, User, LoginResult } from '../graphql.classes'; 6 | import { UserDocument } from '../users/schemas/user.schema'; 7 | import { ConfigService } from '../config/config.service'; 8 | import { resolve } from 'path'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | constructor( 13 | @Inject(forwardRef(() => UsersService)) 14 | private usersService: UsersService, 15 | private jwtService: JwtService, 16 | private configService: ConfigService, 17 | ) {} 18 | 19 | /** 20 | * Checks if a user's password is valid 21 | * 22 | * @param {LoginUserInput} loginAttempt Include username or email. If both are provided only 23 | * username will be used. Password must be provided. 24 | * @returns {(Promise)} returns the User and token if successful, undefined if not 25 | * @memberof AuthService 26 | */ 27 | async validateUserByPassword( 28 | loginAttempt: LoginUserInput, 29 | ): Promise { 30 | // This will be used for the initial login 31 | let userToAttempt: UserDocument | undefined; 32 | if (loginAttempt.email) { 33 | userToAttempt = await this.usersService.findOneByEmail( 34 | loginAttempt.email, 35 | ); 36 | } else if (loginAttempt.username) { 37 | userToAttempt = await this.usersService.findOneByUsername( 38 | loginAttempt.username, 39 | ); 40 | } 41 | 42 | // If the user is not enabled, disable log in - the token wouldn't work anyways 43 | if (userToAttempt && userToAttempt.enabled === false) 44 | userToAttempt = undefined; 45 | 46 | if (!userToAttempt) return undefined; 47 | 48 | // Check the supplied password against the hash stored for this email address 49 | let isMatch = false; 50 | try { 51 | isMatch = await userToAttempt.checkPassword(loginAttempt.password); 52 | } catch (error) { 53 | return undefined; 54 | } 55 | 56 | if (isMatch) { 57 | // If there is a successful match, generate a JWT for the user 58 | const token = this.createJwt(userToAttempt!).token; 59 | const result: LoginResult = { 60 | user: userToAttempt!, 61 | token, 62 | }; 63 | userToAttempt.lastSeenAt = new Date(); 64 | userToAttempt.save(); 65 | return result; 66 | } 67 | 68 | return undefined; 69 | } 70 | 71 | /** 72 | * Verifies that the JWT payload associated with a JWT is valid by making sure the user exists and is enabled 73 | * 74 | * @param {JwtPayload} payload 75 | * @returns {(Promise)} returns undefined if there is no user or the account is not enabled 76 | * @memberof AuthService 77 | */ 78 | async validateJwtPayload( 79 | payload: JwtPayload, 80 | ): Promise { 81 | // This will be used when the user has already logged in and has a JWT 82 | const user = await this.usersService.findOneByUsername(payload.username); 83 | 84 | // Ensure the user exists and their account isn't disabled 85 | if (user && user.enabled) { 86 | user.lastSeenAt = new Date(); 87 | user.save(); 88 | return user; 89 | } 90 | 91 | return undefined; 92 | } 93 | 94 | /** 95 | * Creates a JwtPayload for the given User 96 | * 97 | * @param {User} user 98 | * @returns {{ data: JwtPayload; token: string }} The data contains the email, username, and expiration of the 99 | * token depending on the environment variable. Expiration could be undefined if there is none set. token is the 100 | * token created by signing the data. 101 | * @memberof AuthService 102 | */ 103 | createJwt(user: User): { data: JwtPayload; token: string } { 104 | const expiresIn = this.configService.jwtExpiresIn; 105 | let expiration: Date | undefined; 106 | if (expiresIn) { 107 | expiration = new Date(); 108 | expiration.setTime(expiration.getTime() + expiresIn * 1000); 109 | } 110 | const data: JwtPayload = { 111 | email: user.email, 112 | username: user.username, 113 | expiration, 114 | }; 115 | 116 | const jwt = this.jwtService.sign(data); 117 | 118 | return { 119 | data, 120 | token: jwt, 121 | }; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/auth/auth.types.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | login(user: LoginUserInput!): LoginResult! 3 | refreshToken: String! 4 | } 5 | 6 | type LoginResult { 7 | user: User! 8 | token: String! 9 | } 10 | 11 | input LoginUserInput { 12 | username: String 13 | email: String 14 | password: String! 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/guards/admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { User } from '../../graphql.classes'; 5 | import { UsersService } from '../../users/users.service'; 6 | import { AuthenticationError } from 'apollo-server-core'; 7 | 8 | // Check if username in field for query matches authenticated user's username 9 | // or if the user is admin 10 | @Injectable() 11 | export class AdminGuard implements CanActivate { 12 | constructor(private usersService: UsersService) {} 13 | 14 | canActivate(context: ExecutionContext): boolean { 15 | const ctx = GqlExecutionContext.create(context); 16 | const request = ctx.getContext().req; 17 | if (request.user) { 18 | const user = request.user; 19 | if (this.usersService.isAdmin(user.permissions)) return true; 20 | } 21 | throw new AuthenticationError( 22 | 'Could not authenticate with token or user does not have permissions', 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ExecutionContext } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { AuthenticationError } from 'apollo-server-core'; 5 | 6 | @Injectable() 7 | // In order to use AuthGuard together with GraphQL, you have to extend 8 | // the built-in AuthGuard class and override getRequest() method. 9 | export class JwtAuthGuard extends AuthGuard('jwt') { 10 | getRequest(context: ExecutionContext) { 11 | const ctx = GqlExecutionContext.create(context); 12 | const request = ctx.getContext().req; 13 | return request; 14 | } 15 | 16 | handleRequest(err: any, user: any, info: any) { 17 | if (err || !user) { 18 | throw err || new AuthenticationError('Could not authenticate with token'); 19 | } 20 | return user; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/guards/username-email-admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { User } from '../../graphql.classes'; 4 | import { UsersService } from '../../users/users.service'; 5 | import { AuthenticationError } from 'apollo-server-core'; 6 | import { Reflector } from '@nestjs/core'; 7 | 8 | // Check if username in field for query matches authenticated user's username 9 | // or if the user is admin 10 | @Injectable() 11 | export class UsernameEmailAdminGuard implements CanActivate { 12 | constructor( 13 | private usersService: UsersService, 14 | private readonly reflector: Reflector, 15 | ) {} 16 | 17 | // Returns an array of all the properties of an object seperated by a . 18 | getPropertiesArray(object: any): string[] { 19 | let result: string[] = []; 20 | Object.entries(object).forEach(([key, value]) => { 21 | const field = key; 22 | if (typeof value === 'object' && value !== null) { 23 | const objectProperties = this.getPropertiesArray(value).map( 24 | prop => `${field}.${prop}`, 25 | ); 26 | result = result.concat(objectProperties); 27 | } else { 28 | result.push(field); 29 | } 30 | }); 31 | return result; 32 | } 33 | 34 | canActivate(context: ExecutionContext): boolean { 35 | const ctx = GqlExecutionContext.create(context); 36 | const request = ctx.getContext().req; 37 | let shouldActivate = false; 38 | if (request.user) { 39 | const user = request.user; 40 | const args = ctx.getArgs(); 41 | if (args.username && typeof args.username === 'string') { 42 | shouldActivate = 43 | args.username.toLowerCase() === user.username.toLowerCase(); 44 | } else if (args.email && typeof args.email === 'string') { 45 | shouldActivate = args.email.toLowerCase() === user.email.toLowerCase(); 46 | } else if (!args.username && !args.email) { 47 | shouldActivate = true; 48 | } 49 | 50 | if ( 51 | shouldActivate === false && 52 | this.usersService.isAdmin(user.permissions) 53 | ) { 54 | const adminAllowedArgs = this.reflector.get( 55 | 'adminAllowedArgs', 56 | context.getHandler(), 57 | ); 58 | 59 | shouldActivate = true; 60 | 61 | if (adminAllowedArgs) { 62 | const argFields = this.getPropertiesArray(args); 63 | argFields.forEach(field => { 64 | if (!adminAllowedArgs.includes(field)) { 65 | throw new AuthenticationError( 66 | `Admin is not allowed to modify ${field}`, 67 | ); 68 | } 69 | }); 70 | } 71 | } 72 | } 73 | if (!shouldActivate) { 74 | throw new AuthenticationError('Could not authenticate with token'); 75 | } 76 | return shouldActivate; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/auth/guards/username-email.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { User } from '../../graphql.classes'; 5 | import { UsersService } from '../../users/users.service'; 6 | import { AuthenticationError } from 'apollo-server-core'; 7 | 8 | // Check if username in field for query matches authenticated user's username 9 | // or if the user is admin 10 | @Injectable() 11 | export class UsernameEmailGuard implements CanActivate { 12 | constructor(private usersService: UsersService) {} 13 | 14 | canActivate(context: ExecutionContext): boolean { 15 | const ctx = GqlExecutionContext.create(context); 16 | const request = ctx.getContext().req; 17 | let shouldActivate = false; 18 | if (request.user) { 19 | const user = request.user; 20 | const args = ctx.getArgs(); 21 | if (args.username && typeof args.username === 'string') { 22 | shouldActivate = 23 | args.username.toLowerCase() === user.username.toLowerCase(); 24 | } else if (args.email && typeof args.email === 'string') { 25 | shouldActivate = args.email.toLowerCase() === user.email.toLowerCase(); 26 | } else if (!args.username && !args.email) { 27 | shouldActivate = true; 28 | } 29 | } 30 | if (!shouldActivate) { 31 | throw new AuthenticationError('Could not authenticate with token'); 32 | } 33 | return shouldActivate; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/auth/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | email: string; 3 | username: string; 4 | expiration?: Date; 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ExtractJwt, Strategy } from 'passport-jwt'; 3 | import { AuthService } from '../auth.service'; 4 | import { PassportStrategy } from '@nestjs/passport'; 5 | import { JwtPayload } from '../interfaces/jwt-payload.interface'; 6 | import { AuthenticationError } from 'apollo-server-core'; 7 | import { ConfigService } from '../../config/config.service'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor(private authService: AuthService, configService: ConfigService) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | secretOrKey: configService.jwtSecret, 15 | }); 16 | } 17 | 18 | // Documentation for this here: https://www.npmjs.com/package/passport-jwt 19 | async validate(payload: JwtPayload) { 20 | // This is called to validate the user in the token exists 21 | const user = await this.authService.validateJwtPayload(payload); 22 | 23 | if (!user) { 24 | throw new AuthenticationError( 25 | 'Could not log-in with the provided credentials', 26 | ); 27 | } 28 | 29 | return user; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from './config.service'; 3 | 4 | @Module({ 5 | providers: [ 6 | { 7 | provide: ConfigService, 8 | useValue: new ConfigService(`${process.env.NODE_ENV}.env`), 9 | }, 10 | ], 11 | exports: [ConfigService], 12 | }) 13 | export class ConfigModule {} 14 | -------------------------------------------------------------------------------- /src/config/config.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ConfigService } from './config.service'; 3 | 4 | describe('ConfigService', () => { 5 | let service: ConfigService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ 10 | ConfigService, 11 | { 12 | provide: ConfigService, 13 | useValue: new ConfigService(`${process.env.NODE_ENV}.env`), 14 | }, 15 | ], 16 | }).compile(); 17 | 18 | service = module.get(ConfigService); 19 | }); 20 | 21 | it('should be defined', () => { 22 | expect(service).toBeDefined(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as dotenv from 'dotenv'; 3 | import * as fs from 'fs'; 4 | import * as Joi from 'joi'; 5 | 6 | export interface EnvConfig { 7 | [key: string]: string; 8 | } 9 | 10 | @Injectable() 11 | export class ConfigService { 12 | private readonly envConfig: EnvConfig; 13 | 14 | constructor(filePath: string) { 15 | let file: Buffer | undefined; 16 | try { 17 | file = fs.readFileSync(filePath); 18 | } catch (error) { 19 | file = fs.readFileSync('development.env'); 20 | } 21 | 22 | const config = dotenv.parse(file); 23 | this.envConfig = this.validateInput(config); 24 | } 25 | 26 | private validateInput(envConfig: EnvConfig): EnvConfig { 27 | const envVarsSchema: Joi.ObjectSchema = Joi.object({ 28 | MONGO_URI: Joi.string().required(), 29 | MONGO_AUTH_ENABLED: Joi.boolean().default(false), 30 | MONGO_USER: Joi.string().when('MONGO_AUTH_ENABLED', { 31 | is: true, 32 | then: Joi.required(), 33 | }), 34 | MONGO_PASSWORD: Joi.string().when('MONGO_AUTH_ENABLED', { 35 | is: true, 36 | then: Joi.required(), 37 | }), 38 | IMAGES_URL: Joi.string().default('http://localhost:3000/images/'), 39 | JWT_SECRET: Joi.string().required(), 40 | JWT_EXPIRES_IN: Joi.number(), 41 | EMAIL_ENABLED: Joi.boolean().default(false), 42 | EMAIL_SERVICE: Joi.string().when('EMAIL_ENABLED', { 43 | is: true, 44 | then: Joi.required(), 45 | }), 46 | EMAIL_USERNAME: Joi.string().when('EMAIL_ENABLED', { 47 | is: true, 48 | then: Joi.required(), 49 | }), 50 | EMAIL_PASSWORD: Joi.string().when('EMAIL_ENABLED', { 51 | is: true, 52 | then: Joi.required(), 53 | }), 54 | EMAIL_FROM: Joi.string().when('EMAIL_ENABLED', { 55 | is: true, 56 | then: Joi.required(), 57 | }), 58 | TEST_EMAIL_TO: Joi.string(), 59 | }); 60 | 61 | const { error, value: validatedEnvConfig } = Joi.validate(envConfig, envVarsSchema); 62 | if (error) { 63 | throw new Error(`Config validation error in your env file: ${error.message}`); 64 | } 65 | return validatedEnvConfig; 66 | } 67 | 68 | get jwtExpiresIn(): number | undefined { 69 | if (this.envConfig.JWT_EXPIRES_IN) { 70 | return +this.envConfig.JWT_EXPIRES_IN; 71 | } 72 | return undefined; 73 | } 74 | 75 | get mongoUri(): string { 76 | return this.envConfig.MONGO_URI; 77 | } 78 | 79 | get jwtSecret(): string { 80 | return this.envConfig.JWT_SECRET; 81 | } 82 | 83 | get imagesUrl(): string { 84 | return this.envConfig.IMAGES_URL; 85 | } 86 | 87 | get emailService(): string | undefined { 88 | return this.envConfig.EMAIL_SERVICE; 89 | } 90 | 91 | get emailUsername(): string | undefined { 92 | return this.envConfig.EMAIL_USERNAME; 93 | } 94 | 95 | get emailPassword(): string | undefined { 96 | return this.envConfig.EMAIL_PASSWORD; 97 | } 98 | 99 | get emailFrom(): string | undefined { 100 | return this.envConfig.EMAIL_FROM; 101 | } 102 | 103 | get testEmailTo(): string | undefined { 104 | return this.envConfig.TEST_EMAIL_TO; 105 | } 106 | 107 | get mongoUser(): string | undefined { 108 | return this.envConfig.MONGO_USER; 109 | } 110 | 111 | get mongoPassword(): string | undefined { 112 | return this.envConfig.MONGO_PASSWORD; 113 | } 114 | 115 | get emailEnabled(): boolean { 116 | return Boolean(this.envConfig.EMAIL_ENABLED).valueOf(); 117 | } 118 | 119 | get mongoAuthEnabled(): boolean { 120 | return Boolean(this.envConfig.MONGO_AUTH_ENABLED).valueOf(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/decorators/admin-allowed-args.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const AdminAllowedArgs = (...adminAllowedArgs: string[]) => 4 | SetMetadata('adminAllowedArgs', adminAllowedArgs); 5 | -------------------------------------------------------------------------------- /src/graphql.classes.ts: -------------------------------------------------------------------------------- 1 | 2 | /** ------------------------------------------------------ 3 | * THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 4 | * ------------------------------------------------------- 5 | */ 6 | 7 | /* tslint:disable */ 8 | /* eslint-disable */ 9 | export class CreateUserInput { 10 | username: string; 11 | email: string; 12 | password: string; 13 | } 14 | 15 | export class LoginUserInput { 16 | username?: string; 17 | email?: string; 18 | password: string; 19 | } 20 | 21 | export class UpdatePasswordInput { 22 | oldPassword: string; 23 | newPassword: string; 24 | } 25 | 26 | export class UpdateUserInput { 27 | username?: string; 28 | email?: string; 29 | password?: UpdatePasswordInput; 30 | enabled?: boolean; 31 | } 32 | 33 | export class LoginResult { 34 | user: User; 35 | token: string; 36 | } 37 | 38 | export abstract class IMutation { 39 | abstract createUser(createUserInput?: CreateUserInput): User | Promise; 40 | 41 | abstract updateUser(fieldsToUpdate: UpdateUserInput, username?: string): User | Promise; 42 | 43 | abstract addAdminPermission(username: string): User | Promise; 44 | 45 | abstract removeAdminPermission(username: string): User | Promise; 46 | 47 | abstract resetPassword(username: string, code: string, password: string): User | Promise; 48 | } 49 | 50 | export abstract class IQuery { 51 | abstract login(user: LoginUserInput): LoginResult | Promise; 52 | 53 | abstract refreshToken(): string | Promise; 54 | 55 | abstract users(): User[] | Promise; 56 | 57 | abstract user(username?: string, email?: string): User | Promise; 58 | 59 | abstract forgotPassword(email?: string): boolean | Promise; 60 | } 61 | 62 | export class User { 63 | username: string; 64 | email: string; 65 | permissions: string[]; 66 | createdAt: Date; 67 | updatedAt: Date; 68 | lastSeenAt: Date; 69 | enabled: boolean; 70 | _id: ObjectId; 71 | } 72 | 73 | export type ObjectId = any; 74 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | app.enableCors(); 7 | await app.listen(3000); 8 | } 9 | bootstrap(); 10 | -------------------------------------------------------------------------------- /src/scalars/date.scalar.ts: -------------------------------------------------------------------------------- 1 | import { Scalar } from '@nestjs/graphql'; 2 | import { Kind, ASTNode } from 'graphql'; 3 | 4 | @Scalar('Date') 5 | export class DateScalar { 6 | description = 'Date custom scalar type'; 7 | 8 | parseValue(value: Date) { 9 | return new Date(value); // value from the client 10 | } 11 | 12 | serialize(value: Date) { 13 | return value.getTime(); // value sent to the client 14 | } 15 | 16 | parseLiteral(ast: ASTNode) { 17 | if (ast.kind === Kind.INT) { 18 | return parseInt(ast.value, 10); // ast value is always in string format 19 | } 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/scalars/object-id.scalar.ts: -------------------------------------------------------------------------------- 1 | import { Scalar } from '@nestjs/graphql'; 2 | import { Kind, ASTNode } from 'graphql'; 3 | import { Types } from 'mongoose'; 4 | 5 | @Scalar('ObjectId') 6 | export class ObjectIdScalar { 7 | description = 'MongoDB ObjectId scalar type, sent as 24 byte Hex String'; 8 | 9 | parseValue(value: string) { 10 | return new Types.ObjectId(value); // value from the client 11 | } 12 | 13 | serialize(value: Types.ObjectId) { 14 | return value.toHexString(); // value sent to the client 15 | } 16 | 17 | parseLiteral(ast: ASTNode) { 18 | if (ast.kind === Kind.STRING) { 19 | return new Types.ObjectId(ast.value); // ast value is always in string format 20 | } 21 | return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/users/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Model, Document, Query, Types } from 'mongoose'; 2 | import * as bcrypt from 'bcrypt'; 3 | import { User } from '../../graphql.classes'; 4 | 5 | export interface UserDocument extends User, Document { 6 | // Declaring everything that is not in the GraphQL Schema for a User 7 | password: string; 8 | lowercaseUsername: string; 9 | lowercaseEmail: string; 10 | passwordReset?: { 11 | token: string; 12 | expiration: Date; 13 | }; 14 | 15 | /** 16 | * Checks if the user's password provided matches the user's password hash 17 | * 18 | * @param {string} password The password to attempt 19 | * @returns {Promise} result of the match. Will throw an error if one exists from bcrypt 20 | */ 21 | checkPassword(password: string): Promise; 22 | } 23 | 24 | export interface IUserModel extends Model { 25 | /** 26 | * Uses the same method as the schema to validate an email. Matches HTML 5.2 spec. 27 | * 28 | * @param {string} email address to validate 29 | * @returns {boolean} if the email is valid 30 | * @memberof IUserModel 31 | */ 32 | validateEmail(email: string): boolean; 33 | } 34 | 35 | export const PasswordResetSchema: Schema = new Schema({ 36 | token: { type: String, required: true }, 37 | expiration: { type: Date, required: true }, 38 | }); 39 | 40 | export const UserSchema: Schema = new Schema( 41 | { 42 | email: { 43 | type: String, 44 | unique: true, 45 | required: true, 46 | validate: { validator: validateEmail }, 47 | }, 48 | password: { 49 | type: String, 50 | required: true, 51 | }, 52 | username: { 53 | type: String, 54 | unique: true, 55 | required: true, 56 | }, 57 | permissions: { 58 | type: [String], 59 | required: true, 60 | }, 61 | lowercaseUsername: { 62 | type: String, 63 | unique: true, 64 | }, 65 | lowercaseEmail: { 66 | type: String, 67 | unique: true, 68 | }, 69 | passwordReset: PasswordResetSchema, 70 | enabled: { 71 | type: Boolean, 72 | default: true, 73 | }, 74 | lastSeenAt: { 75 | type: Date, 76 | default: Date.now, 77 | }, 78 | }, 79 | { 80 | timestamps: true, 81 | }, 82 | ); 83 | 84 | function validateEmail(email: string) { 85 | // tslint:disable-next-line:max-line-length 86 | const expression = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; 87 | return expression.test(email); 88 | } 89 | 90 | // NOTE: Arrow functions are not used here as we do not want to use lexical scope for 'this' 91 | UserSchema.pre('save', function(next) { 92 | const user = this; 93 | 94 | user.lowercaseUsername = user.username.toLowerCase(); 95 | user.lowercaseEmail = user.email.toLowerCase(); 96 | 97 | // Make sure not to rehash the password if it is already hashed 98 | if (!user.isModified('password')) { 99 | return next(); 100 | } 101 | 102 | // Generate a salt and use it to hash the user's password 103 | bcrypt.genSalt(10, (genSaltError, salt) => { 104 | if (genSaltError) { 105 | return next(genSaltError); 106 | } 107 | 108 | bcrypt.hash(user.password, salt, (err, hash) => { 109 | if (err) { 110 | return next(err); 111 | } 112 | user.password = hash; 113 | next(); 114 | }); 115 | }); 116 | }); 117 | 118 | UserSchema.pre>('findOneAndUpdate', function(next) { 119 | const updateFields = this.getUpdate(); 120 | 121 | if (updateFields.username) { 122 | this.update( 123 | {}, 124 | { $set: { lowercaseUsername: updateFields.username.toLowerCase() } }, 125 | ); 126 | } 127 | 128 | if (updateFields.email) { 129 | this.update( 130 | {}, 131 | { $set: { lowercaseEmail: updateFields.email.toLowerCase() } }, 132 | ); 133 | } 134 | 135 | // Generate a salt and use it to hash the user's password 136 | if (updateFields.password) { 137 | bcrypt.genSalt(10, (genSaltError, salt) => { 138 | if (genSaltError) { 139 | return next(genSaltError); 140 | } 141 | 142 | bcrypt.hash(updateFields.password, salt, (err, hash) => { 143 | if (err) { 144 | return next(err); 145 | } 146 | updateFields.password = hash; 147 | next(); 148 | }); 149 | }); 150 | } else { 151 | next(); 152 | } 153 | }); 154 | 155 | UserSchema.methods.checkPassword = function( 156 | password: string, 157 | ): Promise { 158 | const user = this; 159 | 160 | return new Promise((resolve, reject) => { 161 | bcrypt.compare(password, user.password, (error, isMatch) => { 162 | if (error) { 163 | reject(error); 164 | } 165 | 166 | resolve(isMatch); 167 | }); 168 | }); 169 | }; 170 | 171 | // Mongoose Static Method - added so a service can validate an email with the same criteria the schema is using 172 | UserSchema.statics.validateEmail = function(email: string): boolean { 173 | return validateEmail(email); 174 | }; 175 | 176 | export const UserModel: IUserModel = model( 177 | 'User', 178 | UserSchema, 179 | ); 180 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { UserSchema } from './schemas/user.schema'; 5 | import { UserResolver } from './users.resolvers'; 6 | import { DateScalar } from '../scalars/date.scalar'; 7 | import { ConfigModule } from '../config/config.module'; 8 | import { AuthModule } from '../auth/auth.module'; 9 | import { ObjectIdScalar } from '../scalars/object-id.scalar'; 10 | 11 | @Module({ 12 | imports: [ 13 | MongooseModule.forFeature([{ name: 'User', schema: UserSchema }]), 14 | ConfigModule, 15 | forwardRef(() => AuthModule), 16 | ], 17 | exports: [UsersService], 18 | controllers: [], 19 | providers: [UsersService, UserResolver, DateScalar, ObjectIdScalar], 20 | }) 21 | export class UsersModule {} 22 | -------------------------------------------------------------------------------- /src/users/users.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards } from '@nestjs/common'; 2 | import { Args, Mutation, Query, Resolver, Context } from '@nestjs/graphql'; 3 | import { UsersService } from './users.service'; 4 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 5 | import { CreateUserInput, User, UpdateUserInput } from '../graphql.classes'; 6 | import { UsernameEmailAdminGuard } from '../auth/guards/username-email-admin.guard'; 7 | import { AdminGuard } from '../auth/guards/admin.guard'; 8 | import { UserInputError, ValidationError } from 'apollo-server-core'; 9 | import { UserDocument } from './schemas/user.schema'; 10 | import { AdminAllowedArgs } from '../decorators/admin-allowed-args'; 11 | 12 | @Resolver('User') 13 | export class UserResolver { 14 | constructor(private usersService: UsersService) {} 15 | 16 | @Query('users') 17 | @UseGuards(JwtAuthGuard, AdminGuard) 18 | async users(): Promise { 19 | return await this.usersService.getAllUsers(); 20 | } 21 | 22 | @Query('user') 23 | @UseGuards(JwtAuthGuard, UsernameEmailAdminGuard) 24 | async user( 25 | @Args('username') username?: string, 26 | @Args('email') email?: string, 27 | ): Promise { 28 | let user: User | undefined; 29 | if (username) { 30 | user = await this.usersService.findOneByUsername(username); 31 | } else if (email) { 32 | user = await this.usersService.findOneByEmail(email); 33 | } else { 34 | // Is this the best exception for a graphQL error? 35 | throw new ValidationError('A username or email must be included'); 36 | } 37 | 38 | if (user) return user; 39 | throw new UserInputError('The user does not exist'); 40 | } 41 | 42 | // A NotFoundException is intentionally not sent so bots can't search for emails 43 | @Query('forgotPassword') 44 | async forgotPassword(@Args('email') email: string): Promise { 45 | const worked = await this.usersService.forgotPassword(email); 46 | } 47 | 48 | // What went wrong is intentionally not sent (wrong username or code or user not in reset status) 49 | @Mutation('resetPassword') 50 | async resetPassword( 51 | @Args('username') username: string, 52 | @Args('code') code: string, 53 | @Args('password') password: string, 54 | ): Promise { 55 | const user = await this.usersService.resetPassword( 56 | username, 57 | code, 58 | password, 59 | ); 60 | if (!user) throw new UserInputError('The password was not reset'); 61 | return user; 62 | } 63 | 64 | @Mutation('createUser') 65 | async createUser( 66 | @Args('createUserInput') createUserInput: CreateUserInput, 67 | ): Promise { 68 | let createdUser: User | undefined; 69 | try { 70 | createdUser = await this.usersService.create(createUserInput); 71 | } catch (error) { 72 | throw new UserInputError(error.message); 73 | } 74 | return createdUser; 75 | } 76 | 77 | @Mutation('updateUser') 78 | @AdminAllowedArgs( 79 | 'username', 80 | 'fieldsToUpdate.username', 81 | 'fieldsToUpdate.email', 82 | 'fieldsToUpdate.enabled', 83 | ) 84 | @UseGuards(JwtAuthGuard, UsernameEmailAdminGuard) 85 | async updateUser( 86 | @Args('username') username: string, 87 | @Args('fieldsToUpdate') fieldsToUpdate: UpdateUserInput, 88 | @Context('req') request: any, 89 | ): Promise { 90 | let user: UserDocument | undefined; 91 | if (!username && request.user) username = request.user.username; 92 | try { 93 | user = await this.usersService.update(username, fieldsToUpdate); 94 | } catch (error) { 95 | throw new ValidationError(error.message); 96 | } 97 | if (!user) throw new UserInputError('The user does not exist'); 98 | return user; 99 | } 100 | 101 | @Mutation('addAdminPermission') 102 | @UseGuards(JwtAuthGuard, AdminGuard) 103 | async addAdminPermission(@Args('username') username: string): Promise { 104 | const user = await this.usersService.addPermission('admin', username); 105 | if (!user) throw new UserInputError('The user does not exist'); 106 | return user; 107 | } 108 | 109 | @Mutation('removeAdminPermission') 110 | @UseGuards(JwtAuthGuard, AdminGuard) 111 | async removeAdminPermission( 112 | @Args('username') username: string, 113 | ): Promise { 114 | const user = await this.usersService.removePermission('admin', username); 115 | if (!user) throw new UserInputError('The user does not exist'); 116 | return user; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | import { getModelToken } from '@nestjs/mongoose'; 4 | import { UserModel } from './schemas/user.schema'; 5 | import { ConfigService } from '../config/config.service'; 6 | 7 | describe('UsersService', () => { 8 | let service: UsersService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [ 13 | UsersService, 14 | { 15 | provide: getModelToken('User'), 16 | useValue: UserModel, 17 | }, 18 | ConfigService, 19 | { 20 | provide: ConfigService, 21 | useValue: new ConfigService(`${process.env.NODE_ENV}.env`), 22 | }, 23 | ], 24 | }).compile(); 25 | 26 | service = module.get(UsersService); 27 | }); 28 | 29 | it('should be defined', () => { 30 | expect(service).toBeDefined(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Model } from 'mongoose'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { UserDocument, UserModel } from './schemas/user.schema'; 5 | import { CreateUserInput, UpdateUserInput } from '../graphql.classes'; 6 | import { randomBytes } from 'crypto'; 7 | import { createTransport, SendMailOptions } from 'nodemailer'; 8 | import { ConfigService } from '../config/config.service'; 9 | import { MongoError } from 'mongodb'; 10 | import { AuthService } from '../auth/auth.service'; 11 | 12 | @Injectable() 13 | export class UsersService { 14 | constructor( 15 | @InjectModel('User') private readonly userModel: Model, 16 | private configService: ConfigService, 17 | private authService: AuthService, 18 | ) {} 19 | 20 | /** 21 | * Returns if the user has 'admin' set on the permissions array 22 | * 23 | * @param {string[]} permissions permissions property on a User 24 | * @returns {boolean} 25 | * @memberof UsersService 26 | */ 27 | isAdmin(permissions: string[]): boolean { 28 | return permissions.includes('admin'); 29 | } 30 | 31 | /** 32 | * Adds any permission string to the user's permissions array property. Checks if that value exists 33 | * before adding it. 34 | * 35 | * @param {string} permission The permission to add to the user 36 | * @param {string} username The user's username 37 | * @returns {(Promise)} The user Document with the updated permission. Undefined if the 38 | * user does not exist 39 | * @memberof UsersService 40 | */ 41 | async addPermission( 42 | permission: string, 43 | username: string, 44 | ): Promise { 45 | const user = await this.findOneByUsername(username); 46 | if (!user) return undefined; 47 | if (user.permissions.includes(permission)) return user; 48 | user.permissions.push(permission); 49 | await user.save(); 50 | return user; 51 | } 52 | 53 | /** 54 | * Removes any permission string from the user's permissions array property. 55 | * 56 | * @param {string} permission The permission to remove from the user 57 | * @param {string} username The username of the user to remove the permission from 58 | * @returns {(Promise)} Returns undefined if the user does not exist 59 | * @memberof UsersService 60 | */ 61 | async removePermission( 62 | permission: string, 63 | username: string, 64 | ): Promise { 65 | const user = await this.findOneByUsername(username); 66 | if (!user) return undefined; 67 | user.permissions = user.permissions.filter( 68 | userPermission => userPermission !== permission, 69 | ); 70 | await user.save(); 71 | return user; 72 | } 73 | 74 | /** 75 | * Updates a user in the database. If any value is invalid, it will still update the other 76 | * fields of the user. 77 | * 78 | * @param {string} username of the user to update 79 | * @param {UpdateUserInput} fieldsToUpdate The user can update their username, email, password, or enabled. If 80 | * the username is updated, the user's token will no longer work. If the user disables their account, only an admin 81 | * can reenable it 82 | * @returns {(Promise)} Returns undefined if the user cannot be found 83 | * @memberof UsersService 84 | */ 85 | async update( 86 | username: string, 87 | fieldsToUpdate: UpdateUserInput, 88 | ): Promise { 89 | if (fieldsToUpdate.username) { 90 | const duplicateUser = await this.findOneByUsername( 91 | fieldsToUpdate.username, 92 | ); 93 | if (duplicateUser) fieldsToUpdate.username = undefined; 94 | } 95 | 96 | if (fieldsToUpdate.email) { 97 | const duplicateUser = await this.findOneByEmail(fieldsToUpdate.email); 98 | const emailValid = UserModel.validateEmail(fieldsToUpdate.email); 99 | if (duplicateUser || !emailValid) fieldsToUpdate.email = undefined; 100 | } 101 | 102 | const fields: any = {}; 103 | 104 | if (fieldsToUpdate.password) { 105 | if ( 106 | await this.authService.validateUserByPassword({ 107 | username, 108 | password: fieldsToUpdate.password.oldPassword, 109 | }) 110 | ) { 111 | fields.password = fieldsToUpdate.password.newPassword; 112 | } 113 | } 114 | 115 | // Remove undefined keys for update 116 | for (const key in fieldsToUpdate) { 117 | if (typeof fieldsToUpdate[key] !== 'undefined' && key !== 'password') { 118 | fields[key] = fieldsToUpdate[key]; 119 | } 120 | } 121 | 122 | let user: UserDocument | undefined | null = null; 123 | 124 | if (Object.entries(fieldsToUpdate).length > 0) { 125 | user = await this.userModel.findOneAndUpdate( 126 | { lowercaseUsername: username.toLowerCase() }, 127 | fields, 128 | { new: true, runValidators: true }, 129 | ); 130 | } else { 131 | user = await this.findOneByUsername(username); 132 | } 133 | 134 | if (!user) return undefined; 135 | 136 | return user; 137 | } 138 | 139 | /** 140 | * Send an email with a password reset code and sets the reset token and expiration on the user. 141 | * EMAIL_ENABLED must be true for this to run. 142 | * 143 | * @param {string} email address associated with an account to reset 144 | * @returns {Promise} if an email was sent or not 145 | * @memberof UsersService 146 | */ 147 | async forgotPassword(email: string): Promise { 148 | if (!this.configService.emailEnabled) return false; 149 | 150 | const user = await this.findOneByEmail(email); 151 | if (!user) return false; 152 | if (!user.enabled) return false; 153 | 154 | const token = randomBytes(32).toString('hex'); 155 | 156 | // One day for expiration of reset token 157 | const expiration = new Date(Date().valueOf() + 24 * 60 * 60 * 1000); 158 | 159 | const transporter = createTransport({ 160 | service: this.configService.emailService, 161 | auth: { 162 | user: this.configService.emailUsername, 163 | pass: this.configService.emailPassword, 164 | }, 165 | }); 166 | 167 | const mailOptions: SendMailOptions = { 168 | from: this.configService.emailFrom, 169 | to: email, 170 | subject: `Reset Password`, 171 | text: `${user.username}, 172 | Replace this with a website that can pass the token: 173 | ${token}`, 174 | }; 175 | 176 | return new Promise(resolve => { 177 | transporter.sendMail(mailOptions, (err, info) => { 178 | if (err) { 179 | resolve(false); 180 | return; 181 | } 182 | 183 | user.passwordReset = { 184 | token, 185 | expiration, 186 | }; 187 | 188 | user.save().then( 189 | () => resolve(true), 190 | () => resolve(false), 191 | ); 192 | }); 193 | }); 194 | } 195 | 196 | /** 197 | * Resets a password after the user forgot their password and requested a reset 198 | * 199 | * @param {string} username 200 | * @param {string} code the token set when the password reset email was sent out 201 | * @param {string} password the new password the user wants 202 | * @returns {(Promise)} Returns undefined if the code or the username is wrong 203 | * @memberof UsersService 204 | */ 205 | async resetPassword( 206 | username: string, 207 | code: string, 208 | password: string, 209 | ): Promise { 210 | const user = await this.findOneByUsername(username); 211 | if (user && user.passwordReset && user.enabled !== false) { 212 | if (user.passwordReset.token === code) { 213 | user.password = password; 214 | user.passwordReset = undefined; 215 | await user.save(); 216 | return user; 217 | } 218 | } 219 | return undefined; 220 | } 221 | 222 | /** 223 | * Creates a user 224 | * 225 | * @param {CreateUserInput} createUserInput username, email, and password. Username and email must be 226 | * unique, will throw an email with a description if either are duplicates 227 | * @returns {Promise} or throws an error 228 | * @memberof UsersService 229 | */ 230 | async create(createUserInput: CreateUserInput): Promise { 231 | const createdUser = new this.userModel(createUserInput); 232 | 233 | let user: UserDocument | undefined; 234 | try { 235 | user = await createdUser.save(); 236 | } catch (error) { 237 | throw this.evaluateMongoError(error, createUserInput); 238 | } 239 | return user; 240 | } 241 | 242 | /** 243 | * Returns a user by their unique email address or undefined 244 | * 245 | * @param {string} email address of user, not case sensitive 246 | * @returns {(Promise)} 247 | * @memberof UsersService 248 | */ 249 | async findOneByEmail(email: string): Promise { 250 | const user = await this.userModel 251 | .findOne({ lowercaseEmail: email.toLowerCase() }) 252 | .exec(); 253 | if (user) return user; 254 | return undefined; 255 | } 256 | 257 | /** 258 | * Returns a user by their unique username or undefined 259 | * 260 | * @param {string} username of user, not case sensitive 261 | * @returns {(Promise)} 262 | * @memberof UsersService 263 | */ 264 | async findOneByUsername(username: string): Promise { 265 | const user = await this.userModel 266 | .findOne({ lowercaseUsername: username.toLowerCase() }) 267 | .exec(); 268 | if (user) return user; 269 | return undefined; 270 | } 271 | 272 | /** 273 | * Gets all the users that are registered 274 | * 275 | * @returns {Promise} 276 | * @memberof UsersService 277 | */ 278 | async getAllUsers(): Promise { 279 | const users = await this.userModel.find().exec(); 280 | return users; 281 | } 282 | 283 | /** 284 | * Deletes all the users in the database, used for testing 285 | * 286 | * @returns {Promise} 287 | * @memberof UsersService 288 | */ 289 | async deleteAllUsers(): Promise { 290 | await this.userModel.deleteMany({}); 291 | } 292 | 293 | /** 294 | * Reads a mongo database error and attempts to provide a better error message. If 295 | * it is unable to produce a better error message, returns the original error message. 296 | * 297 | * @private 298 | * @param {MongoError} error 299 | * @param {CreateUserInput} createUserInput 300 | * @returns {Error} 301 | * @memberof UsersService 302 | */ 303 | private evaluateMongoError( 304 | error: MongoError, 305 | createUserInput: CreateUserInput, 306 | ): Error { 307 | if (error.code === 11000) { 308 | if ( 309 | error.message 310 | .toLowerCase() 311 | .includes(createUserInput.email.toLowerCase()) 312 | ) { 313 | throw new Error( 314 | `e-mail ${createUserInput.email} is already registered`, 315 | ); 316 | } else if ( 317 | error.message 318 | .toLowerCase() 319 | .includes(createUserInput.username.toLowerCase()) 320 | ) { 321 | throw new Error( 322 | `Username ${createUserInput.username} is already registered`, 323 | ); 324 | } 325 | } 326 | throw new Error(error.message); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/users/users.types.graphql: -------------------------------------------------------------------------------- 1 | scalar Date 2 | scalar ObjectId 3 | 4 | type Query { 5 | users: [User!]! 6 | user(username: String, email: String): User! 7 | forgotPassword(email: String): Boolean 8 | } 9 | 10 | type Mutation { 11 | createUser(createUserInput: CreateUserInput): User! 12 | updateUser(fieldsToUpdate: UpdateUserInput!, username: String): User! 13 | addAdminPermission(username: String!): User! 14 | removeAdminPermission(username: String!): User! 15 | resetPassword(username: String!, code: String!, password: String!): User! 16 | } 17 | 18 | type User { 19 | username: String! 20 | email: String! 21 | permissions: [String!]! 22 | createdAt: Date! 23 | updatedAt: Date! 24 | lastSeenAt: Date! 25 | enabled: Boolean! 26 | _id: ObjectId! 27 | } 28 | 29 | input CreateUserInput { 30 | username: String! 31 | email: String! 32 | password: String! 33 | } 34 | 35 | input UpdateUserInput { 36 | username: String 37 | email: String 38 | password: UpdatePasswordInput 39 | enabled: Boolean 40 | } 41 | 42 | input UpdatePasswordInput { 43 | oldPassword: String! 44 | newPassword: String! 45 | } 46 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/users.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import { UsersService } from '../src/users/users.service'; 6 | import { disconnect } from 'mongoose'; 7 | import { AuthService } from '../src/auth/auth.service'; 8 | import { LoginResult } from '../src/graphql.classes'; 9 | import { ConfigService } from '../src/config/config.service'; 10 | import { JwtService } from '@nestjs/jwt'; 11 | import { UsersModule } from '../src/users/users.module'; 12 | import { AuthModule } from '../src/auth/auth.module'; 13 | import { ConfigModule } from '../src/config/config.module'; 14 | 15 | describe('Users (e2e)', () => { 16 | let app: INestApplication; 17 | let user1Login: LoginResult; 18 | let user2Login: LoginResult; 19 | let adminLogin: LoginResult; 20 | let disabledUserLogin: LoginResult; 21 | let disabledAdminLogin: LoginResult; 22 | let usersService: UsersService; 23 | let configService: ConfigService; 24 | let authService: AuthService; 25 | let jwtService: JwtService; 26 | 27 | beforeAll(async () => { 28 | const moduleFixture: TestingModule = await Test.createTestingModule({ 29 | imports: [AppModule, UsersModule, AuthModule, ConfigModule], 30 | providers: [], 31 | }).compile(); 32 | 33 | app = moduleFixture.createNestApplication(); 34 | await app.init(); 35 | 36 | usersService = moduleFixture.get(UsersService); 37 | configService = moduleFixture.get(ConfigService); 38 | authService = moduleFixture.get(AuthService); 39 | jwtService = moduleFixture.get(JwtService); 40 | 41 | await usersService.deleteAllUsers(); 42 | 43 | await usersService.create({ 44 | username: 'user1', 45 | email: 'user1@email.com', 46 | password: 'password1', 47 | }); 48 | 49 | await usersService.create({ 50 | username: 'user2', 51 | email: 'user2@email.com', 52 | password: 'password2', 53 | }); 54 | 55 | await usersService.create({ 56 | username: 'disabledUser', 57 | email: 'disabledUser@email.com', 58 | password: 'password', 59 | }); 60 | 61 | await usersService.create({ 62 | username: 'admin', 63 | email: 'admin@email.com', 64 | password: 'password', 65 | }); 66 | 67 | await usersService.create({ 68 | username: 'disabledAdmin', 69 | email: 'disabledAdmin@email.com', 70 | password: 'password', 71 | }); 72 | 73 | const adminDocument = await usersService.findOneByUsername('admin'); 74 | 75 | adminDocument!.permissions = ['admin']; 76 | await adminDocument!.save(); 77 | 78 | let result = await authService.validateUserByPassword({ 79 | username: 'user1', 80 | password: 'password1', 81 | }); 82 | if (result) user1Login = result; 83 | 84 | result = await authService.validateUserByPassword({ 85 | username: 'user2', 86 | password: 'password2', 87 | }); 88 | if (result) user2Login = result; 89 | 90 | result = await authService.validateUserByPassword({ 91 | username: 'admin', 92 | password: 'password', 93 | }); 94 | if (result) adminLogin = result; 95 | 96 | result = await authService.validateUserByPassword({ 97 | username: 'disabledUser', 98 | password: 'password', 99 | }); 100 | if (result) disabledUserLogin = result; 101 | 102 | result = await authService.validateUserByPassword({ 103 | username: 'disabledAdmin', 104 | password: 'password', 105 | }); 106 | if (result) disabledAdminLogin = result; 107 | 108 | await usersService.update('disabledUser', { enabled: false }); 109 | await usersService.update('disabledAdmin', { enabled: false }); 110 | }); 111 | 112 | describe('login', () => { 113 | it('works', () => { 114 | const data = { 115 | query: `{login(user:{username:"user1",password:"password1"}){token user{username}}}`, 116 | }; 117 | return request(app.getHttpServer()) 118 | .post('/graphql') 119 | .send(data) 120 | .expect(200) 121 | .expect(response => { 122 | expect(response.body.data.login.user).toMatchObject({ 123 | username: 'user1', 124 | }); 125 | }); 126 | }); 127 | 128 | it('works with different cases on username', () => { 129 | const data = { 130 | query: `{login(user:{username:"uSer1",password:"password1"}){token user{username}}}`, 131 | }; 132 | return request(app.getHttpServer()) 133 | .post('/graphql') 134 | .send(data) 135 | .expect(200) 136 | .expect(response => { 137 | expect(response.body.data.login.user).toMatchObject({ 138 | username: 'user1', 139 | }); 140 | }); 141 | }); 142 | 143 | it('fails for disabled user', () => { 144 | const data = { 145 | query: `{login(user:{username:"disabledUser",password:"password"}){token user{username}}}`, 146 | }; 147 | return request(app.getHttpServer()) 148 | .post('/graphql') 149 | .send(data) 150 | .expect(200) 151 | .expect(response => { 152 | expect(response.body.errors[0].extensions.code).toEqual( 153 | 'UNAUTHENTICATED', 154 | ); 155 | }); 156 | }); 157 | 158 | it('fails for disabled admin', () => { 159 | const data = { 160 | query: `{login(user:{username:"disabledAdmin",password:"password"}){token user{username}}}`, 161 | }; 162 | return request(app.getHttpServer()) 163 | .post('/graphql') 164 | .send(data) 165 | .expect(200) 166 | .expect(response => { 167 | expect(response.body.errors[0].extensions.code).toEqual( 168 | 'UNAUTHENTICATED', 169 | ); 170 | }); 171 | }); 172 | 173 | it('fails password', () => { 174 | const data = { 175 | query: `{login(user:{username:"user1",password:"pAssword1"}){token user{username}}}`, 176 | }; 177 | return request(app.getHttpServer()) 178 | .post('/graphql') 179 | .send(data) 180 | .expect(200) 181 | .expect(response => { 182 | expect(response.body.errors[0].extensions.code).toEqual( 183 | 'UNAUTHENTICATED', 184 | ); 185 | }); 186 | }); 187 | 188 | it('fails username', () => { 189 | const data = { 190 | query: `{login(user:{username:"notAUser",password:"password1"}){token user{username}}}`, 191 | }; 192 | return request(app.getHttpServer()) 193 | .post('/graphql') 194 | .send(data) 195 | .expect(200) 196 | .expect(response => { 197 | expect(response.body).toHaveProperty('errors'); 198 | expect(response.body.errors[0].extensions.code).toEqual( 199 | 'UNAUTHENTICATED', 200 | ); 201 | }); 202 | }); 203 | }); 204 | 205 | describe('refresh token', () => { 206 | it('works', async () => { 207 | const data = { 208 | query: `{refreshToken}`, 209 | }; 210 | 211 | await new Promise(resolve => { 212 | setTimeout(resolve, 1000); 213 | }); 214 | await request(app.getHttpServer()) 215 | .post('/graphql') 216 | .set('Authorization', `Bearer ${user1Login.token}`) 217 | .send(data) 218 | .expect(200) 219 | .expect(response => { 220 | expect(response.body.data).toHaveProperty('refreshToken'); 221 | const newToken = response.body.data.refreshToken; 222 | const verified = jwtService.verify(newToken); 223 | expect(verified).toBeTruthy(); 224 | const newtokenIssued = new Date(verified.iat * 1000); 225 | const oldTokenIssued = new Date( 226 | jwtService.verify(user1Login.token).iat * 1000, 227 | ); 228 | expect( 229 | newtokenIssued.valueOf() - oldTokenIssued.valueOf(), 230 | ).toBeGreaterThan(0); 231 | }); 232 | }); 233 | 234 | it('fails for disabled user', () => { 235 | const data = { 236 | query: `{refreshToken}`, 237 | }; 238 | return request(app.getHttpServer()) 239 | .post('/graphql') 240 | .set('Authorization', `Bearer ${disabledUserLogin.token}`) 241 | .send(data) 242 | .expect(200) 243 | .expect(response => { 244 | expect(response.body.errors[0].extensions.code).toEqual( 245 | 'UNAUTHENTICATED', 246 | ); 247 | }); 248 | }); 249 | 250 | it('fails for disabled admin', () => { 251 | const data = { 252 | query: `{refreshToken}`, 253 | }; 254 | return request(app.getHttpServer()) 255 | .post('/graphql') 256 | .set('Authorization', `Bearer ${disabledAdminLogin.token}`) 257 | .send(data) 258 | .expect(200) 259 | .expect(response => { 260 | expect(response.body.errors[0].extensions.code).toEqual( 261 | 'UNAUTHENTICATED', 262 | ); 263 | }); 264 | }); 265 | 266 | it('fails with no token', () => { 267 | const data = { 268 | query: `{refreshToken}`, 269 | }; 270 | return request(app.getHttpServer()) 271 | .post('/graphql') 272 | .send(data) 273 | .expect(200) 274 | .expect(response => { 275 | expect(response.body.errors[0].extensions.code).toEqual( 276 | 'UNAUTHENTICATED', 277 | ); 278 | }); 279 | }); 280 | 281 | it('fails with mispelled token', () => { 282 | const data = { 283 | query: `{refreshToken}`, 284 | }; 285 | return request(app.getHttpServer()) 286 | .post('/graphql') 287 | .set('Authorization', `Bearer ${user1Login.token}a`) 288 | .send(data) 289 | .expect(200) 290 | .expect(response => { 291 | expect(response.body.errors[0].extensions.code).toEqual( 292 | 'UNAUTHENTICATED', 293 | ); 294 | }); 295 | }); 296 | }); 297 | 298 | describe('get user info', () => { 299 | it('works with username', () => { 300 | const data = { 301 | query: `{user(username:"uSer1"){username}}`, 302 | }; 303 | return request(app.getHttpServer()) 304 | .post('/graphql') 305 | .set('Authorization', `Bearer ${user1Login.token}`) 306 | .send(data) 307 | .expect(200) 308 | .expect(response => { 309 | expect(response.body.data.user).toMatchObject({ 310 | username: 'user1', 311 | }); 312 | }); 313 | }); 314 | 315 | it('works for admin', () => { 316 | const data = { 317 | query: `{user(username:"uSer1"){username}}`, 318 | }; 319 | return request(app.getHttpServer()) 320 | .post('/graphql') 321 | .set('Authorization', `Bearer ${adminLogin.token}`) 322 | .send(data) 323 | .expect(200) 324 | .expect(response => { 325 | expect(response.body.data.user).toMatchObject({ 326 | username: 'user1', 327 | }); 328 | }); 329 | }); 330 | 331 | it('works with email', () => { 332 | const data = { 333 | query: `{user(email:"uSer1@email.com"){username}}`, 334 | }; 335 | return request(app.getHttpServer()) 336 | .post('/graphql') 337 | .set('Authorization', `Bearer ${user1Login.token}`) 338 | .send(data) 339 | .expect(200) 340 | .expect(response => { 341 | expect(response.body.data.user).toMatchObject({ 342 | username: 'user1', 343 | }); 344 | }); 345 | }); 346 | 347 | it('fails with wrong username', () => { 348 | const data = { 349 | query: `{user(username:"user10"){username}}`, 350 | }; 351 | return request(app.getHttpServer()) 352 | .post('/graphql') 353 | .set('Authorization', `Bearer ${user1Login.token}`) 354 | .send(data) 355 | .expect(200) 356 | .expect(response => { 357 | expect(response.body.errors[0].extensions.code).toEqual( 358 | 'UNAUTHENTICATED', 359 | ); 360 | }); 361 | }); 362 | 363 | it('fails for disabled user', () => { 364 | const data = { 365 | query: `{user(username:"disabledUser"){username}}`, 366 | }; 367 | return request(app.getHttpServer()) 368 | .post('/graphql') 369 | .set('Authorization', `Bearer ${disabledUserLogin.token}`) 370 | .send(data) 371 | .expect(200) 372 | .expect(response => { 373 | expect(response.body.errors[0].extensions.code).toEqual( 374 | 'UNAUTHENTICATED', 375 | ); 376 | }); 377 | }); 378 | 379 | it('fails for disabled admin', () => { 380 | const data = { 381 | query: `{user(username:"disabledAdmin"){username}}`, 382 | }; 383 | return request(app.getHttpServer()) 384 | .post('/graphql') 385 | .set('Authorization', `Bearer ${disabledAdminLogin.token}`) 386 | .send(data) 387 | .expect(200) 388 | .expect(response => { 389 | expect(response.body.errors[0].extensions.code).toEqual( 390 | 'UNAUTHENTICATED', 391 | ); 392 | }); 393 | }); 394 | 395 | it('fails with wrong token', () => { 396 | const data = { 397 | query: `{user(username:"user2"){username}}`, 398 | }; 399 | return request(app.getHttpServer()) 400 | .post('/graphql') 401 | .set('Authorization', `Bearer ${user1Login.token}`) 402 | .send(data) 403 | .expect(200) 404 | .expect(response => { 405 | expect(response.body.errors[0].extensions.code).toEqual( 406 | 'UNAUTHENTICATED', 407 | ); 408 | }); 409 | }); 410 | 411 | it('fails with no token', () => { 412 | const data = { 413 | query: `{user(username:"user10"){username}}`, 414 | }; 415 | return request(app.getHttpServer()) 416 | .post('/graphql') 417 | .send(data) 418 | .expect(200) 419 | .expect(response => { 420 | expect(response.body.errors[0].extensions.code).toEqual( 421 | 'UNAUTHENTICATED', 422 | ); 423 | }); 424 | }); 425 | 426 | it('fails with mispelled token', () => { 427 | const data = { 428 | query: `{user(username:"user10"){username}}`, 429 | }; 430 | return request(app.getHttpServer()) 431 | .post('/graphql') 432 | .set('Authorization', `Bearer ${user1Login.token}a`) 433 | .send(data) 434 | .expect(200) 435 | .expect(response => { 436 | expect(response.body.errors[0].extensions.code).toEqual( 437 | 'UNAUTHENTICATED', 438 | ); 439 | }); 440 | }); 441 | }); 442 | 443 | describe('get users', () => { 444 | it('works with admin', () => { 445 | const data = { 446 | query: `{users{username}}`, 447 | }; 448 | return request(app.getHttpServer()) 449 | .post('/graphql') 450 | .set('Authorization', `Bearer ${adminLogin.token}`) 451 | .send(data) 452 | .expect(200) 453 | .expect(response => { 454 | expect(response.body.data.users).toContainEqual({ 455 | username: 'user1', 456 | }); 457 | expect(response.body.data.users).toContainEqual({ 458 | username: 'user2', 459 | }); 460 | expect(response.body.data.users).toContainEqual({ 461 | username: 'admin', 462 | }); 463 | expect(response.body.data.users).toContainEqual({ 464 | username: 'disabledUser', 465 | }); 466 | expect(response.body.data.users).toContainEqual({ 467 | username: 'disabledAdmin', 468 | }); 469 | }); 470 | }); 471 | 472 | it('fails for disabled admin', () => { 473 | const data = { 474 | query: `{users{username}}`, 475 | }; 476 | return request(app.getHttpServer()) 477 | .post('/graphql') 478 | .set('Authorization', `Bearer ${disabledAdminLogin.token}`) 479 | .send(data) 480 | .expect(200) 481 | .expect(response => { 482 | expect(response.body.errors[0].extensions.code).toEqual( 483 | 'UNAUTHENTICATED', 484 | ); 485 | }); 486 | }); 487 | 488 | it('fails with non admin token', () => { 489 | const data = { 490 | query: `{users{username}}`, 491 | }; 492 | return request(app.getHttpServer()) 493 | .post('/graphql') 494 | .set('Authorization', `Bearer ${user1Login.token}`) 495 | .send(data) 496 | .expect(200) 497 | .expect(response => { 498 | expect(response.body.errors[0].extensions.code).toEqual( 499 | 'UNAUTHENTICATED', 500 | ); 501 | }); 502 | }); 503 | 504 | it('fails with no token', () => { 505 | const data = { 506 | query: `{users{username}}`, 507 | }; 508 | return request(app.getHttpServer()) 509 | .post('/graphql') 510 | .send(data) 511 | .expect(200) 512 | .expect(response => { 513 | expect(response.body.errors[0].extensions.code).toEqual( 514 | 'UNAUTHENTICATED', 515 | ); 516 | }); 517 | }); 518 | }); 519 | 520 | describe('forgot password and reset password', () => { 521 | let testEmailTo: string | undefined; 522 | let runEmailTests = false; 523 | let testRequest: request.Test; 524 | beforeAll(async () => { 525 | testEmailTo = configService.testEmailTo; 526 | if (testEmailTo && configService.emailEnabled) { 527 | runEmailTests = true; 528 | await usersService.create({ 529 | username: 'userForgotPassword', 530 | email: testEmailTo, 531 | password: 'oldPassword', 532 | }); 533 | 534 | const data = { 535 | query: `{forgotPassword(email: "${testEmailTo}")}`, 536 | }; 537 | testRequest = request(app.getHttpServer()) 538 | .post('/graphql') 539 | .send(data); 540 | } 541 | }); 542 | 543 | it('responds with 200', () => { 544 | if (runEmailTests) return testRequest.expect(200); 545 | }); 546 | 547 | it('modifies the user with token and reset password works', async () => { 548 | if (runEmailTests) { 549 | let user = await usersService.findOneByEmail(testEmailTo!); 550 | expect(user!.passwordReset).toBeTruthy(); 551 | expect(user!.passwordReset!.token).toBeTruthy(); 552 | expect(user!.passwordReset!.expiration).toBeInstanceOf(Date); 553 | 554 | // Bad token 555 | let data = { 556 | query: `mutation {resetPassword( 557 | username: "userForgotPassword" 558 | code: "${user!.passwordReset!.token}a" 559 | password: "newPassword") { 560 | username 561 | } 562 | }`, 563 | }; 564 | await request(app.getHttpServer()) 565 | .post('/graphql') 566 | .send(data) 567 | .expect(200) 568 | .expect(response => { 569 | expect(response.body.errors[0].extensions.code).toEqual( 570 | 'BAD_USER_INPUT', 571 | ); 572 | }); 573 | 574 | // Bad username 575 | data = { 576 | query: `mutation {resetPassword( 577 | username: "userForgotPassword2" 578 | code: "${user!.passwordReset!.token}" 579 | password: "newPassword") { 580 | username 581 | } 582 | }`, 583 | }; 584 | await request(app.getHttpServer()) 585 | .post('/graphql') 586 | .send(data) 587 | .expect(200) 588 | .expect(response => { 589 | expect(response.body.errors[0].extensions.code).toEqual( 590 | 'BAD_USER_INPUT', 591 | ); 592 | }); 593 | 594 | // Correct data being passed 595 | data = { 596 | query: `mutation {resetPassword( 597 | username: "userForgotPassword" 598 | code: "${user!.passwordReset!.token}" 599 | password: "newPassword") { 600 | username 601 | } 602 | }`, 603 | }; 604 | await request(app.getHttpServer()) 605 | .post('/graphql') 606 | .send(data) 607 | .expect(200) 608 | .expect(response => { 609 | expect(response.body.data.resetPassword).toMatchObject({ 610 | username: `userForgotPassword`, 611 | }); 612 | }); 613 | 614 | // Verify that the new password works 615 | expect( 616 | await authService.validateUserByPassword({ 617 | username: `userForgotPassword`, 618 | password: `newPassword`, 619 | }), 620 | ).toBeTruthy(); 621 | 622 | // Ensure the token was removed from the user 623 | user = await usersService.findOneByEmail(testEmailTo!); 624 | expect(user!.passwordReset).toBeFalsy(); 625 | 626 | // Make sure the old password does not work 627 | expect( 628 | await authService.validateUserByPassword({ 629 | username: `userForgotPassword`, 630 | password: `oldPassword`, 631 | }), 632 | ).toBeFalsy(); 633 | } 634 | }); 635 | }); 636 | 637 | describe('create user', () => { 638 | it('works', () => { 639 | const data = { 640 | query: `mutation { 641 | createUser(createUserInput: { 642 | username: "user3", 643 | email:"user3@email.com", 644 | password:"password" 645 | }) {username}}`, 646 | }; 647 | return request(app.getHttpServer()) 648 | .post('/graphql') 649 | .send(data) 650 | .expect(200) 651 | .expect(response => { 652 | expect(response.body.data.createUser).toMatchObject({ 653 | username: 'user3', 654 | }); 655 | }); 656 | }); 657 | 658 | it('fails for duplicate username', () => { 659 | const data = { 660 | query: `mutation { 661 | createUser(createUserInput: { 662 | username: "usEr2", 663 | email:"user4@email.com", 664 | password:"password" 665 | }) {username}}`, 666 | }; 667 | return request(app.getHttpServer()) 668 | .post('/graphql') 669 | .send(data) 670 | .expect(200) 671 | .expect(response => { 672 | expect(response.body.errors[0].extensions.code).toEqual( 673 | 'BAD_USER_INPUT', 674 | ); 675 | }); 676 | }); 677 | 678 | it('fails for duplicate email', () => { 679 | const data = { 680 | query: `mutation { 681 | createUser(createUserInput: { 682 | username: "user4", 683 | email:"user2@emAil.com", 684 | password:"password" 685 | }) {username}}`, 686 | }; 687 | return request(app.getHttpServer()) 688 | .post('/graphql') 689 | .send(data) 690 | .expect(200) 691 | .expect(response => { 692 | expect(response.body.errors[0].extensions.code).toEqual( 693 | 'BAD_USER_INPUT', 694 | ); 695 | }); 696 | }); 697 | 698 | it('fails for no username', () => { 699 | const data = { 700 | query: `mutation { 701 | createUser(createUserInput: { 702 | email:"user5@email.com", 703 | password:"password" 704 | }) {username}}`, 705 | }; 706 | return request(app.getHttpServer()) 707 | .post('/graphql') 708 | .send(data) 709 | .expect(400) 710 | .expect(response => { 711 | expect(response.body.errors[0].extensions.code).toEqual( 712 | 'GRAPHQL_VALIDATION_FAILED', 713 | ); 714 | }); 715 | }); 716 | 717 | it('fails for no password', () => { 718 | const data = { 719 | query: `mutation { 720 | createUser(createUserInput: { 721 | username: "user5", 722 | email:"user5@email.com" 723 | }) {username}}`, 724 | }; 725 | return request(app.getHttpServer()) 726 | .post('/graphql') 727 | .send(data) 728 | .expect(400) 729 | .expect(response => { 730 | expect(response.body.errors[0].extensions.code).toEqual( 731 | 'GRAPHQL_VALIDATION_FAILED', 732 | ); 733 | }); 734 | }); 735 | 736 | it('fails for no email', () => { 737 | const data = { 738 | query: `mutation { 739 | createUser(createUserInput: { 740 | username: "user5", 741 | password:"password" 742 | }) {username}}`, 743 | }; 744 | return request(app.getHttpServer()) 745 | .post('/graphql') 746 | .send(data) 747 | .expect(400) 748 | .expect(response => { 749 | expect(response.body.errors[0].extensions.code).toEqual( 750 | 'GRAPHQL_VALIDATION_FAILED', 751 | ); 752 | }); 753 | }); 754 | 755 | it('fails for bad email, no @', () => { 756 | const data = { 757 | query: `mutation { 758 | createUser(createUserInput: { 759 | username: "user5", 760 | password: "password", 761 | email: "email.com" 762 | }) {username}}`, 763 | }; 764 | return request(app.getHttpServer()) 765 | .post('/graphql') 766 | .send(data) 767 | .expect(200) 768 | .expect(response => { 769 | expect(response.body.errors[0].extensions.code).toEqual( 770 | 'BAD_USER_INPUT', 771 | ); 772 | }); 773 | }); 774 | }); 775 | 776 | describe('update user', () => { 777 | it('works with all fields', async () => { 778 | await usersService.create({ 779 | username: 'userToUpdate1', 780 | email: 'userToUpdate1@email.com', 781 | password: 'password', 782 | }); 783 | 784 | const result = await authService.validateUserByPassword({ 785 | username: 'userToUpdate1', 786 | password: 'password', 787 | }); 788 | const token = result!.token; 789 | 790 | const data = { 791 | query: `mutation { 792 | updateUser( 793 | username: "userToUpdate1", 794 | fieldsToUpdate: { 795 | username: "newUsername1", 796 | email: "newUser1@email.com", 797 | password: { 798 | oldPassword: "password", 799 | newPassword: "newPassword", 800 | } 801 | enabled: true 802 | }) {username, email, enabled}}`, 803 | }; 804 | 805 | await request(app.getHttpServer()) 806 | .post('/graphql') 807 | .set('Authorization', `Bearer ${token!}`) 808 | .send(data) 809 | .expect(200) 810 | .expect(response => { 811 | expect(response.body.data.updateUser).toMatchObject({ 812 | username: 'newUsername1', 813 | email: 'newUser1@email.com', 814 | enabled: true, 815 | }); 816 | }); 817 | 818 | const login = await authService.validateUserByPassword({ 819 | username: 'newUsername1', 820 | password: 'newPassword', 821 | }); 822 | expect(login).toBeTruthy(); 823 | }); 824 | 825 | it('disables a user', async () => { 826 | await usersService.create({ 827 | username: 'userToDisable', 828 | email: 'userToDisable@email.com', 829 | password: 'password', 830 | }); 831 | 832 | const result = await authService.validateUserByPassword({ 833 | username: 'userToDisable', 834 | password: 'password', 835 | }); 836 | const token = result!.token; 837 | 838 | const data = { 839 | query: `mutation { 840 | updateUser( 841 | username: "userToDisable", 842 | fieldsToUpdate: { 843 | enabled: false 844 | }) {enabled}}`, 845 | }; 846 | 847 | await request(app.getHttpServer()) 848 | .post('/graphql') 849 | .set('Authorization', `Bearer ${token!}`) 850 | .send(data) 851 | .expect(200) 852 | .expect(response => { 853 | expect(response.body.data.updateUser).toMatchObject({ 854 | enabled: false, 855 | }); 856 | }); 857 | 858 | const login = await authService.validateUserByPassword({ 859 | username: 'userToDisable', 860 | password: 'password', 861 | }); 862 | expect(login).not.toBeTruthy(); 863 | }); 864 | 865 | it('works with for admin', async () => { 866 | await usersService.create({ 867 | username: 'userToUpdateByAdmin', 868 | email: 'userToUpdatebyAdmin@email.com', 869 | password: 'password', 870 | }); 871 | 872 | const data = { 873 | query: `mutation { 874 | updateUser( 875 | username: "userToUpdateByAdmin", 876 | fieldsToUpdate: { 877 | username: "newUsernameByAdmin", 878 | email: "newUserByAdmin@email.com", 879 | }) {username, email}}`, 880 | }; 881 | 882 | await request(app.getHttpServer()) 883 | .post('/graphql') 884 | .set('Authorization', `Bearer ${adminLogin.token!}`) 885 | .send(data) 886 | .expect(200) 887 | .expect(response => { 888 | expect(response.body.data.updateUser).toMatchObject({ 889 | username: 'newUsernameByAdmin', 890 | email: 'newUserByAdmin@email.com', 891 | }); 892 | }); 893 | }); 894 | 895 | it("fails with admin changing another user's password", async () => { 896 | await usersService.create({ 897 | username: 'userToUpdateByAdmin2', 898 | email: 'userToUpdatebyAdmin2@email.com', 899 | password: 'password', 900 | }); 901 | 902 | const data = { 903 | query: `mutation { 904 | updateUser( 905 | username: "userToUpdateByAdmin2", 906 | fieldsToUpdate: { 907 | username: "newUsernameByAdmin2", 908 | email: "newUserByAdmin2@email.com", 909 | password: { 910 | oldPassword: "password", 911 | newPassword: "newPassword", 912 | } 913 | }) {username, email}}`, 914 | }; 915 | 916 | await request(app.getHttpServer()) 917 | .post('/graphql') 918 | .set('Authorization', `Bearer ${adminLogin.token!}`) 919 | .send(data) 920 | .expect(200) 921 | .expect(response => { 922 | expect(response.body.errors[0].extensions.code).toEqual( 923 | 'UNAUTHENTICATED', 924 | ); 925 | }); 926 | }); 927 | 928 | it('updates other fields with changed username already in use', async () => { 929 | await usersService.create({ 930 | username: 'userToUpdate2', 931 | email: 'userToUpdate2@email.com', 932 | password: 'password', 933 | }); 934 | 935 | const result = await authService.validateUserByPassword({ 936 | username: 'userToUpdate2', 937 | password: 'password', 938 | }); 939 | const token = result!.token; 940 | 941 | const data = { 942 | query: `mutation { 943 | updateUser(username: "userToUpdate2", 944 | fieldsToUpdate: { 945 | username: "user1", 946 | email:"newUser2@email.com", 947 | password: { 948 | oldPassword: "password", 949 | newPassword: "newPassword", 950 | } 951 | }) {username, email}}`, 952 | }; 953 | 954 | await request(app.getHttpServer()) 955 | .post('/graphql') 956 | .set('Authorization', `Bearer ${token}`) 957 | .send(data) 958 | .expect(200) 959 | .expect(response => { 960 | expect(response.body.data.updateUser).toMatchObject({ 961 | username: 'userToUpdate2', 962 | email: 'newUser2@email.com', 963 | }); 964 | }); 965 | 966 | expect( 967 | await authService.validateUserByPassword({ 968 | username: 'uSerToUpdate2', 969 | password: 'newPassword', 970 | }), 971 | ).toBeTruthy(); 972 | }); 973 | 974 | it('updates other fields with email already in use', async () => { 975 | await usersService.create({ 976 | username: 'userToUpdate3', 977 | email: 'userToUpdate3@email.com', 978 | password: 'password', 979 | }); 980 | 981 | const result = await authService.validateUserByPassword({ 982 | username: 'userToUpdate3', 983 | password: 'password', 984 | }); 985 | const token = result!.token; 986 | 987 | const data = { 988 | query: `mutation { 989 | updateUser(username: "userToUpdate3", 990 | fieldsToUpdate: { 991 | username: "newUsername3", 992 | email:"user1@email.com", 993 | password: { 994 | oldPassword: "password", 995 | newPassword: "newPassword", 996 | } 997 | }) {username, email}}`, 998 | }; 999 | 1000 | await request(app.getHttpServer()) 1001 | .post('/graphql') 1002 | .set('Authorization', `Bearer ${token}`) 1003 | .send(data) 1004 | .expect(200) 1005 | .expect(response => { 1006 | expect(response.body.data.updateUser).toMatchObject({ 1007 | username: 'newUsername3', 1008 | email: 'userToUpdate3@email.com', 1009 | }); 1010 | }); 1011 | 1012 | expect( 1013 | await authService.validateUserByPassword({ 1014 | username: 'newUsername3', 1015 | password: 'newPassword', 1016 | }), 1017 | ).toBeTruthy(); 1018 | }); 1019 | 1020 | it('updates other fields with invalid email', async () => { 1021 | await usersService.create({ 1022 | username: 'userToUpdate4', 1023 | email: 'userToUpdate4@email.com', 1024 | password: 'password', 1025 | }); 1026 | 1027 | const result = await authService.validateUserByPassword({ 1028 | username: 'userToUpdate4', 1029 | password: 'password', 1030 | }); 1031 | const token = result!.token; 1032 | 1033 | const data = { 1034 | query: `mutation { 1035 | updateUser(username: "userToUpdate4", 1036 | fieldsToUpdate: { 1037 | username: "newUsername5", 1038 | email:"invalidEmail", 1039 | password: { 1040 | oldPassword: "password", 1041 | newPassword: "newPassword", 1042 | } 1043 | }) {username, email}}`, 1044 | }; 1045 | 1046 | await request(app.getHttpServer()) 1047 | .post('/graphql') 1048 | .set('Authorization', `Bearer ${token}`) 1049 | .send(data) 1050 | .expect(200) 1051 | .expect(response => { 1052 | expect(response.body.data.updateUser).toMatchObject({ 1053 | username: 'newUsername5', 1054 | email: 'userToUpdate4@email.com', 1055 | }); 1056 | }); 1057 | 1058 | expect( 1059 | await authService.validateUserByPassword({ 1060 | username: 'newUsername5', 1061 | password: 'newPassword', 1062 | }), 1063 | ).toBeTruthy(); 1064 | }); 1065 | 1066 | it('updates with no fields returns the user', async () => { 1067 | const data = { 1068 | query: `mutation { 1069 | updateUser(username: "user1", 1070 | fieldsToUpdate: {}) 1071 | {username, email}}`, 1072 | }; 1073 | 1074 | await request(app.getHttpServer()) 1075 | .post('/graphql') 1076 | .set('Authorization', `Bearer ${user1Login.token}`) 1077 | .send(data) 1078 | .expect(200) 1079 | .expect(response => { 1080 | expect(response.body.data.updateUser).toMatchObject({ 1081 | username: 'user1', 1082 | email: 'user1@email.com', 1083 | }); 1084 | }); 1085 | 1086 | expect( 1087 | await authService.validateUserByPassword({ 1088 | username: 'user1', 1089 | password: 'password1', 1090 | }), 1091 | ).toBeTruthy(); 1092 | }); 1093 | 1094 | it(`update other fields with bad old password - also verifies updates requesting user's info 1095 | with no username set`, async () => { 1096 | await usersService.create({ 1097 | username: 'userToUpdate55', 1098 | email: 'userToUpdate55@email.com', 1099 | password: 'password', 1100 | }); 1101 | 1102 | const result = await authService.validateUserByPassword({ 1103 | username: 'userToUpdate55', 1104 | password: 'password', 1105 | }); 1106 | const token = result!.token; 1107 | 1108 | const data = { 1109 | query: `mutation { 1110 | updateUser( 1111 | fieldsToUpdate: { 1112 | username: "newUsername55", 1113 | email: "newUser55@email.com", 1114 | password: { 1115 | oldPassword: "notthepassword", 1116 | newPassword: "newPassword", 1117 | } 1118 | enabled: true 1119 | }) {username, email, enabled}}`, 1120 | }; 1121 | 1122 | await request(app.getHttpServer()) 1123 | .post('/graphql') 1124 | .set('Authorization', `Bearer ${token!}`) 1125 | .send(data) 1126 | .expect(200) 1127 | .expect(response => { 1128 | expect(response.body.data.updateUser).toMatchObject({ 1129 | username: 'newUsername55', 1130 | email: 'newUser55@email.com', 1131 | enabled: true, 1132 | }); 1133 | }); 1134 | 1135 | let login = await authService.validateUserByPassword({ 1136 | username: 'newUsername55', 1137 | password: 'newPassword', 1138 | }); 1139 | expect(login).toBeFalsy(); 1140 | 1141 | login = await authService.validateUserByPassword({ 1142 | username: 'newUsername55', 1143 | password: 'password', 1144 | }); 1145 | expect(login).toBeTruthy(); 1146 | }); 1147 | 1148 | it('fails to update with wrong username', async () => { 1149 | const data = { 1150 | query: `mutation { 1151 | updateUser(username: "user2", 1152 | fieldsToUpdate: {email: "newEmail11@email.com"}) 1153 | {username, email}}`, 1154 | }; 1155 | 1156 | await request(app.getHttpServer()) 1157 | .post('/graphql') 1158 | .set('Authorization', `Bearer ${user1Login.token}`) 1159 | .send(data) 1160 | .expect(200) 1161 | .expect(response => { 1162 | expect(response.body.errors[0].extensions.code).toEqual( 1163 | 'UNAUTHENTICATED', 1164 | ); 1165 | }); 1166 | }); 1167 | 1168 | it('fails to update with username that does not exist', async () => { 1169 | const data = { 1170 | query: `mutation { 1171 | updateUser(username: "doesNotExist", 1172 | fieldsToUpdate: {email: "newEmail11@email.com"}) 1173 | {username, email}}`, 1174 | }; 1175 | 1176 | await request(app.getHttpServer()) 1177 | .post('/graphql') 1178 | .set('Authorization', `Bearer ${user1Login.token}`) 1179 | .send(data) 1180 | .expect(200) 1181 | .expect(response => { 1182 | expect(response.body.errors[0].extensions.code).toEqual( 1183 | 'UNAUTHENTICATED', 1184 | ); 1185 | }); 1186 | }); 1187 | 1188 | it('fails to update with no token', async () => { 1189 | const data = { 1190 | query: `mutation { 1191 | updateUser(username: "user1", 1192 | fieldsToUpdate: {email: "newEmail11@email.com"}) 1193 | {username, email}}`, 1194 | }; 1195 | 1196 | await request(app.getHttpServer()) 1197 | .post('/graphql') 1198 | .send(data) 1199 | .expect(200) 1200 | .expect(response => { 1201 | expect(response.body.errors[0].extensions.code).toEqual( 1202 | 'UNAUTHENTICATED', 1203 | ); 1204 | }); 1205 | }); 1206 | 1207 | it('fails to update with invalid token', async () => { 1208 | const data = { 1209 | query: `mutation { 1210 | updateUser(username: "user1", 1211 | fieldsToUpdate: {email: "newEmail11@email.com"}) 1212 | {username, email}}`, 1213 | }; 1214 | 1215 | await request(app.getHttpServer()) 1216 | .post('/graphql') 1217 | .set('Authorization', `Bearer ${user1Login.token}a`) 1218 | .send(data) 1219 | .expect(200) 1220 | .expect(response => { 1221 | expect(response.body.errors[0].extensions.code).toEqual( 1222 | 'UNAUTHENTICATED', 1223 | ); 1224 | }); 1225 | }); 1226 | 1227 | it('fails for disabled user', async () => { 1228 | const data = { 1229 | query: `mutation { 1230 | updateUser(username: "disabledUser", 1231 | fieldsToUpdate: {email: "newEmail11@email.com"}) 1232 | {username, email}}`, 1233 | }; 1234 | 1235 | await request(app.getHttpServer()) 1236 | .post('/graphql') 1237 | .set('Authorization', `Bearer ${disabledUserLogin.token}`) 1238 | .send(data) 1239 | .expect(200) 1240 | .expect(response => { 1241 | expect(response.body.errors[0].extensions.code).toEqual( 1242 | 'UNAUTHENTICATED', 1243 | ); 1244 | }); 1245 | }); 1246 | }); 1247 | 1248 | describe('admin permissions', () => { 1249 | it('adds and removes the permission', async () => { 1250 | await usersService.create({ 1251 | username: 'userToBeAdmin', 1252 | email: 'userToBeAdmin@email.com', 1253 | password: 'password', 1254 | }); 1255 | 1256 | let data = { 1257 | query: `mutation {addAdminPermission(username: "userToBeAdmin") {permissions}}`, 1258 | }; 1259 | 1260 | await request(app.getHttpServer()) 1261 | .post('/graphql') 1262 | .set('Authorization', `Bearer ${adminLogin.token}`) 1263 | .send(data) 1264 | .expect(200) 1265 | .expect(response => { 1266 | expect( 1267 | response.body.data.addAdminPermission.permissions, 1268 | ).toContainEqual(`admin`); 1269 | }); 1270 | 1271 | // Make sure admin isn't added twice 1272 | data = { 1273 | query: `mutation {addAdminPermission(username: "userToBeAdmin") {permissions}}`, 1274 | }; 1275 | 1276 | await request(app.getHttpServer()) 1277 | .post('/graphql') 1278 | .set('Authorization', `Bearer ${adminLogin.token}`) 1279 | .send(data) 1280 | .expect(200) 1281 | .expect(response => { 1282 | expect( 1283 | response.body.data.addAdminPermission.permissions, 1284 | ).toContainEqual(`admin`); 1285 | expect( 1286 | response.body.data.addAdminPermission.permissions, 1287 | ).toHaveLength(1); 1288 | }); 1289 | 1290 | // Can remove admin 1291 | data = { 1292 | query: `mutation {removeAdminPermission(username: "userToBeAdmin") {permissions}}`, 1293 | }; 1294 | 1295 | await request(app.getHttpServer()) 1296 | .post('/graphql') 1297 | .set('Authorization', `Bearer ${adminLogin.token}`) 1298 | .send(data) 1299 | .expect(200) 1300 | .expect(response => { 1301 | expect( 1302 | response.body.data.removeAdminPermission.permissions, 1303 | ).not.toContainEqual(`admin`); 1304 | expect( 1305 | response.body.data.removeAdminPermission.permissions, 1306 | ).toHaveLength(0); 1307 | }); 1308 | 1309 | // Make sure there are no issues when removing an adming where it doesn't exist 1310 | data = { 1311 | query: `mutation {removeAdminPermission(username: "userToBeAdmin") {permissions}}`, 1312 | }; 1313 | 1314 | await request(app.getHttpServer()) 1315 | .post('/graphql') 1316 | .set('Authorization', `Bearer ${adminLogin.token}`) 1317 | .send(data) 1318 | .expect(200) 1319 | .expect(response => { 1320 | expect( 1321 | response.body.data.removeAdminPermission.permissions, 1322 | ).not.toContainEqual(`admin`); 1323 | expect( 1324 | response.body.data.removeAdminPermission.permissions, 1325 | ).toHaveLength(0); 1326 | }); 1327 | }); 1328 | 1329 | it('fails for user', async () => { 1330 | // Own user's token 1331 | const data = { 1332 | query: `mutation {addAdminPermission(username: "user1") {permissions}}`, 1333 | }; 1334 | 1335 | await request(app.getHttpServer()) 1336 | .post('/graphql') 1337 | .set('Authorization', `Bearer ${user1Login.token}`) 1338 | .send(data) 1339 | .expect(200) 1340 | .expect(response => { 1341 | expect(response.body.errors[0].extensions.code).toEqual( 1342 | 'UNAUTHENTICATED', 1343 | ); 1344 | }); 1345 | }); 1346 | 1347 | it('fails for user trying to update another', async () => { 1348 | // Another user's token (non-admin) 1349 | const data = { 1350 | query: `mutation {addAdminPermission(username: "user1") {permissions}}`, 1351 | }; 1352 | 1353 | await request(app.getHttpServer()) 1354 | .post('/graphql') 1355 | .set('Authorization', `Bearer ${user2Login.token}`) 1356 | .send(data) 1357 | .expect(200) 1358 | .expect(response => { 1359 | expect(response.body.errors[0].extensions.code).toEqual( 1360 | 'UNAUTHENTICATED', 1361 | ); 1362 | }); 1363 | }); 1364 | 1365 | it('fails for disabled user', async () => { 1366 | const data = { 1367 | query: `mutation {addAdminPermission(username: "disabledUser") {permissions}}`, 1368 | }; 1369 | 1370 | await request(app.getHttpServer()) 1371 | .post('/graphql') 1372 | .set('Authorization', `Bearer ${disabledUserLogin.token}`) 1373 | .send(data) 1374 | .expect(200) 1375 | .expect(response => { 1376 | expect(response.body.errors[0].extensions.code).toEqual( 1377 | 'UNAUTHENTICATED', 1378 | ); 1379 | }); 1380 | }); 1381 | 1382 | it('fails for disabled admin', async () => { 1383 | const data = { 1384 | query: `mutation {addAdminPermission(username: "user1") {permissions}}`, 1385 | }; 1386 | 1387 | await request(app.getHttpServer()) 1388 | .post('/graphql') 1389 | .set('Authorization', `Bearer ${disabledAdminLogin.token}`) 1390 | .send(data) 1391 | .expect(200) 1392 | .expect(response => { 1393 | expect(response.body.errors[0].extensions.code).toEqual( 1394 | 'UNAUTHENTICATED', 1395 | ); 1396 | }); 1397 | }); 1398 | 1399 | it('fails for no token', async () => { 1400 | const data = { 1401 | query: `mutation {addAdminPermission(username: "user1") {permissions}}`, 1402 | }; 1403 | 1404 | await request(app.getHttpServer()) 1405 | .post('/graphql') 1406 | .send(data) 1407 | .expect(200) 1408 | .expect(response => { 1409 | expect(response.body.errors[0].extensions.code).toEqual( 1410 | 'UNAUTHENTICATED', 1411 | ); 1412 | }); 1413 | }); 1414 | 1415 | it('fails for invalid token', async () => { 1416 | const data = { 1417 | query: `mutation {addAdminPermission(username: "user1") {permissions}}`, 1418 | }; 1419 | 1420 | await request(app.getHttpServer()) 1421 | .post('/graphql') 1422 | .set('Authorization', `Bearer ${user1Login.token}a`) 1423 | .send(data) 1424 | .expect(200) 1425 | .expect(response => { 1426 | expect(response.body.errors[0].extensions.code).toEqual( 1427 | 'UNAUTHENTICATED', 1428 | ); 1429 | }); 1430 | }); 1431 | }); 1432 | 1433 | afterAll(() => { 1434 | disconnect(); 1435 | }); 1436 | }); 1437 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | //"declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | //"baseUrl": ".", 12 | "strict": true, 13 | "strictPropertyInitialization": false, 14 | "skipLibCheck": true, 15 | "suppressImplicitAnyIndexErrors": true 16 | }, 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 120], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false, 16 | "no-angle-bracket-type-assertion": false, 17 | "curly": false, 18 | "no-empty": false, 19 | "no-console": false, 20 | "only-arrow-functions": false 21 | }, 22 | "rulesDirectory": [], 23 | "linterOptions": { 24 | "exclude": ["node_modules"] 25 | } 26 | } 27 | --------------------------------------------------------------------------------