From 9feeb302e89049843564015c2dc2820ac2886e2d Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 13 Jan 2021 17:44:22 +0100 Subject: [PATCH 1/4] Switched emails to being mandetory for users ref #93 --- src/errors/UserErrors.ts | 14 +++++++++++++- src/models/actions/create/CreateUser.ts | 15 +++++++-------- src/models/actions/update/UpdateUser.ts | 17 ++++++++--------- src/models/entities/User.ts | 5 +++-- src/seeds/SeedUsers.ts | 1 + src/tests/auth/auth_logout.spec.ts | 3 ++- src/tests/auth/auth_refresh.spec.ts | 3 ++- src/tests/auth/auth_reset.spec.ts | 6 ++++-- 8 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/errors/UserErrors.ts b/src/errors/UserErrors.ts index 37f2987..8cee607 100644 --- a/src/errors/UserErrors.ts +++ b/src/errors/UserErrors.ts @@ -4,7 +4,7 @@ import { NotAcceptableError, NotFoundError } from 'routing-controllers'; /** * Error to throw when no username or email is set. - * We somehow need to identify you :) + * We somehow need to identify you on login. */ export class UsernameOrEmailNeededError extends NotFoundError { @IsString() @@ -14,6 +14,18 @@ export class UsernameOrEmailNeededError extends NotFoundError { message = "No username or email is set!" } +/** + * Error to throw when no email is set. + * We somehow need to identify you :) + */ +export class UserEmailNeededError extends NotFoundError { + @IsString() + name = "UserEmailNeededError" + + @IsString() + message = "No email is set! \n You have to provide email addresses for users (used for password reset among others)." +} + /** * Error to throw when a user couldn't be found. */ diff --git a/src/models/actions/create/CreateUser.ts b/src/models/actions/create/CreateUser.ts index a5f20c2..50e5b7b 100644 --- a/src/models/actions/create/CreateUser.ts +++ b/src/models/actions/create/CreateUser.ts @@ -1,9 +1,9 @@ import * as argon2 from "argon2"; -import { IsBoolean, IsEmail, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; import { getConnectionManager } from 'typeorm'; import * as uuid from 'uuid'; import { config } from '../../../config'; -import { UsernameOrEmailNeededError } from '../../../errors/UserErrors'; +import { UserEmailNeededError } from '../../../errors/UserErrors'; import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; import { User } from '../../entities/User'; import { UserGroup } from '../../entities/UserGroup'; @@ -33,7 +33,7 @@ export class CreateUser { /** * The new user's username. - * You have to provide at least one of: {email, username}. + * You have to provide a email addres, so this is optional. */ @IsOptional() @IsString() @@ -41,12 +41,11 @@ export class CreateUser { /** * The new user's email address. - * You have to provide at least one of: {email, username}. */ @IsEmail() @IsString() - @IsOptional() - email?: string; + @IsNotEmpty() + email: string; /** * The new user's phone number. @@ -92,8 +91,8 @@ export class CreateUser { public async toEntity(): Promise { let newUser: User = new User(); - if (this.email === undefined && this.username === undefined) { - throw new UsernameOrEmailNeededError(); + if (!this.email) { + throw new UserEmailNeededError(); } newUser.email = this.email diff --git a/src/models/actions/update/UpdateUser.ts b/src/models/actions/update/UpdateUser.ts index 59d95a4..45726b7 100644 --- a/src/models/actions/update/UpdateUser.ts +++ b/src/models/actions/update/UpdateUser.ts @@ -1,8 +1,8 @@ import * as argon2 from "argon2"; -import { IsBoolean, IsEmail, IsInt, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; +import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; import { getConnectionManager } from 'typeorm'; import { config } from '../../../config'; -import { UsernameOrEmailNeededError } from '../../../errors/AuthError'; +import { UserEmailNeededError } from '../../../errors/UserErrors'; import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; import { User } from '../../entities/User'; import { UserGroup } from '../../entities/UserGroup'; @@ -40,7 +40,7 @@ export class UpdateUser { /** * The updated user's username. - * You have to provide at least one of: {email, username}. + * You have to provide a email addres, so this is optional. */ @IsOptional() @IsString() @@ -48,12 +48,11 @@ export class UpdateUser { /** * The updated user's email address. - * You have to provide at least one of: {email, username}. */ @IsEmail() @IsString() - @IsOptional() - email?: string; + @IsNotEmpty() + email: string; /** * The updated user's phone number. @@ -99,11 +98,11 @@ export class UpdateUser { * @param user The user that shall be updated. */ public async update(user: User): Promise { + if (!this.email) { + throw new UserEmailNeededError(); + } user.email = this.email; user.username = this.username; - if ((user.email === undefined || user.email === null) && (user.username === undefined || user.username === null)) { - throw new UsernameOrEmailNeededError(); - } if (this.password) { user.password = await argon2.hash(this.password + user.uuid); user.refreshTokenCount = user.refreshTokenCount + 1; diff --git a/src/models/entities/User.ts b/src/models/entities/User.ts index 94e091c..f82e899 100644 --- a/src/models/entities/User.ts +++ b/src/models/entities/User.ts @@ -25,9 +25,10 @@ export class User extends Principal { * The user's e-mail address. * Either username or email has to be set (otherwise the user couldn't log in). */ - @Column({ nullable: true, unique: true }) + @Column({ nullable: false, unique: true }) @IsEmail() - email?: string; + @IsNotEmpty() + email: string; /** * The user's phone number. diff --git a/src/seeds/SeedUsers.ts b/src/seeds/SeedUsers.ts index e70ef84..26fc233 100644 --- a/src/seeds/SeedUsers.ts +++ b/src/seeds/SeedUsers.ts @@ -33,6 +33,7 @@ export default class SeedUsers implements Seeder { initialUser.lastname = "demo"; initialUser.username = "demo"; initialUser.password = "demo"; + initialUser.email = "demo@dev.lauf-fuer-kaya.de" initialUser.groups = group; return await connection.getRepository(User).save(await initialUser.toEntity()); } diff --git a/src/tests/auth/auth_logout.spec.ts b/src/tests/auth/auth_logout.spec.ts index 7c846c1..2594f26 100644 --- a/src/tests/auth/auth_logout.spec.ts +++ b/src/tests/auth/auth_logout.spec.ts @@ -14,7 +14,8 @@ beforeAll(async () => { "middlename": "demo_logout", "lastname": "demo_logout", "username": "demo_logout", - "password": "demo_logout" + "password": "demo_logout", + "email": "demo_logout@dev.lauf-fuer-kaya.de" }, { headers: { "authorization": "Bearer " + res_login.data["access_token"] }, validateStatus: undefined diff --git a/src/tests/auth/auth_refresh.spec.ts b/src/tests/auth/auth_refresh.spec.ts index 02d2a02..bd875ba 100644 --- a/src/tests/auth/auth_refresh.spec.ts +++ b/src/tests/auth/auth_refresh.spec.ts @@ -14,7 +14,8 @@ beforeAll(async () => { "middlename": "demo_refresh", "lastname": "demo_refresh", "username": "demo_refresh", - "password": "demo_refresh" + "password": "demo_refresh", + "email": "demo_refresh@dev.lauf-fuer-kaya.de" }, { headers: { "authorization": "Bearer " + res_login.data["access_token"] }, validateStatus: undefined diff --git a/src/tests/auth/auth_reset.spec.ts b/src/tests/auth/auth_reset.spec.ts index 2ebb8ed..47c61e6 100644 --- a/src/tests/auth/auth_reset.spec.ts +++ b/src/tests/auth/auth_reset.spec.ts @@ -14,7 +14,8 @@ beforeAll(async () => { "middlename": "demo_reset", "lastname": "demo_reset", "username": "demo_reset", - "password": "demo_reset" + "password": "demo_reset", + "email": "demo_reset@dev.lauf-fuer-kaya.de" }, { headers: { "authorization": "Bearer " + res_login.data["access_token"] }, validateStatus: undefined @@ -24,7 +25,8 @@ beforeAll(async () => { "middlename": "demo_reset2", "lastname": "demo_reset2", "username": "demo_reset2", - "password": "demo_reset2" + "password": "demo_reset2", + "email": "demo_reset1@dev.lauf-fuer-kaya.de" }, { headers: { "authorization": "Bearer " + res_login.data["access_token"] }, validateStatus: undefined From 37fc167002caa297f865832ba9237a33fb7d9219 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 13 Jan 2021 17:51:42 +0100 Subject: [PATCH 2/4] Added '@' as a illegal character for usernames ref #93 --- src/controllers/UserController.ts | 6 ++++-- src/errors/UserErrors.ts | 12 ++++++++++++ src/models/actions/create/CreateUser.ts | 3 ++- src/models/actions/update/UpdateUser.ts | 8 +++++--- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index bb8d5ee..82fc1f0 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -1,7 +1,7 @@ import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { getConnectionManager, Repository } from 'typeorm'; -import { UserIdsNotMatchingError, UserNotFoundError } from '../errors/UserErrors'; +import { UserIdsNotMatchingError, UsernameContainsIllegalCharacterError, UserNotFoundError } from '../errors/UserErrors'; import { UserGroupNotFoundError } from '../errors/UserGroupErrors'; import { CreateUser } from '../models/actions/create/CreateUser'; import { UpdateUser } from '../models/actions/update/UpdateUser'; @@ -51,7 +51,8 @@ export class UserController { @Post() @Authorized("USER:CREATE") @ResponseSchema(ResponseUser) - @ResponseSchema(UserGroupNotFoundError) + @ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) + @ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) @OpenAPI({ description: 'Create a new user.
If you want to grant permissions to the user you have to create them seperately by posting to /api/permissions after creating the user.' }) async post(@Body({ validate: true }) createUser: CreateUser) { let user; @@ -70,6 +71,7 @@ export class UserController { @ResponseSchema(ResponseUser) @ResponseSchema(UserNotFoundError, { statusCode: 404 }) @ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 }) + @ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) @OpenAPI({ description: "Update the user whose id you provided.
To change the permissions directly granted to the user please use /api/permissions instead.
Please remember that ids can't be changed." }) async put(@Param('id') id: number, @Body({ validate: true }) updateUser: UpdateUser) { let oldUser = await this.userRepository.findOne({ id: id }); diff --git a/src/errors/UserErrors.ts b/src/errors/UserErrors.ts index 8cee607..5d2b659 100644 --- a/src/errors/UserErrors.ts +++ b/src/errors/UserErrors.ts @@ -14,6 +14,18 @@ export class UsernameOrEmailNeededError extends NotFoundError { message = "No username or email is set!" } +/** + * Error to throw when no username contains illegal characters. + * Right now the only one is "@" but this could change in the future. + */ +export class UsernameContainsIllegalCharacterError extends NotAcceptableError { + @IsString() + name = "UsernameContainsIllegalCharacterError" + + @IsString() + message = "The provided username contains illegal characters! \n Right now the following characters are considered illegal: '@'" +} + /** * Error to throw when no email is set. * We somehow need to identify you :) diff --git a/src/models/actions/create/CreateUser.ts b/src/models/actions/create/CreateUser.ts index 50e5b7b..1942e59 100644 --- a/src/models/actions/create/CreateUser.ts +++ b/src/models/actions/create/CreateUser.ts @@ -3,7 +3,7 @@ import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, Is import { getConnectionManager } from 'typeorm'; import * as uuid from 'uuid'; import { config } from '../../../config'; -import { UserEmailNeededError } from '../../../errors/UserErrors'; +import { UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors'; import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; import { User } from '../../entities/User'; import { UserGroup } from '../../entities/UserGroup'; @@ -94,6 +94,7 @@ export class CreateUser { if (!this.email) { throw new UserEmailNeededError(); } + if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); } newUser.email = this.email newUser.username = this.username diff --git a/src/models/actions/update/UpdateUser.ts b/src/models/actions/update/UpdateUser.ts index 45726b7..2797b34 100644 --- a/src/models/actions/update/UpdateUser.ts +++ b/src/models/actions/update/UpdateUser.ts @@ -2,7 +2,7 @@ import * as argon2 from "argon2"; import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; import { getConnectionManager } from 'typeorm'; import { config } from '../../../config'; -import { UserEmailNeededError } from '../../../errors/UserErrors'; +import { UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors'; import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; import { User } from '../../entities/User'; import { UserGroup } from '../../entities/UserGroup'; @@ -101,13 +101,15 @@ export class UpdateUser { if (!this.email) { throw new UserEmailNeededError(); } - user.email = this.email; - user.username = this.username; + if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); } + if (this.password) { user.password = await argon2.hash(this.password + user.uuid); user.refreshTokenCount = user.refreshTokenCount + 1; } + user.email = this.email; + user.username = this.username; user.enabled = this.enabled; user.firstname = this.firstname user.middlename = this.middlename From cd7b15aadfe66353033e976393fc143368ba0ba8 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 13 Jan 2021 17:57:42 +0100 Subject: [PATCH 3/4] First part of resolving user inherited permissions ref #93 --- src/models/entities/User.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/models/entities/User.ts b/src/models/entities/User.ts index f82e899..c9111ff 100644 --- a/src/models/entities/User.ts +++ b/src/models/entities/User.ts @@ -3,6 +3,7 @@ import { ChildEntity, Column, JoinTable, ManyToMany, OneToMany } from "typeorm"; import { config } from '../../config'; import { ResponsePrincipal } from '../responses/ResponsePrincipal'; import { ResponseUser } from '../responses/ResponseUser'; +import { Permission } from './Permission'; import { Principal } from './Principal'; import { UserAction } from './UserAction'; import { UserGroup } from './UserGroup'; @@ -129,8 +130,24 @@ export class User extends Principal { @OneToMany(() => UserAction, action => action.user, { nullable: true }) actions: UserAction[] + /** + * Resolves all permissions granted to this user through groups. + */ + public get inheritedPermissions(): Permission[] { + let returnPermissions: Permission[] = new Array(); + + if (!this.groups) { return returnPermissions; } + for (let group of this.groups) { + for (let permission of group.permissions) { + returnPermissions.push(permission); + } + } + return returnPermissions; + } + /** * Resolves all permissions granted to this user through groups or directly to the string enum format. + * Also deduplicates the array. */ public get allPermissions(): string[] { let returnPermissions: string[] = new Array(); From b01e1eb8a1a36fc8e04e009cd6024495a70a10dd Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 13 Jan 2021 18:19:59 +0100 Subject: [PATCH 4/4] Added a new endpoint that returns a users permissions as objects sorted into two arrays ref #93 --- src/controllers/UserController.ts | 17 +++++++- src/models/responses/ResponseUser.ts | 1 + .../responses/ResponseUserPermissions.ts | 40 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 src/models/responses/ResponseUserPermissions.ts diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index 82fc1f0..846653f 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -8,6 +8,7 @@ import { UpdateUser } from '../models/actions/update/UpdateUser'; import { User } from '../models/entities/User'; import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseUser } from '../models/responses/ResponseUser'; +import { ResponseUserPermissions } from '../models/responses/ResponseUserPermissions'; import { PermissionController } from './PermissionController'; @@ -26,7 +27,7 @@ export class UserController { @Get() @Authorized("USER:GET") @ResponseSchema(ResponseUser, { isArray: true }) - @OpenAPI({ description: 'Lists all users.
This includes their groups and permissions directly granted to them (if existing/associated).' }) + @OpenAPI({ description: 'Lists all users.
This includes their groups and permissions granted to them.' }) async getAll() { let responseUsers: ResponseUser[] = new Array(); const users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'] }); @@ -41,13 +42,25 @@ export class UserController { @ResponseSchema(ResponseUser) @ResponseSchema(UserNotFoundError, { statusCode: 404 }) @OnUndefined(UserNotFoundError) - @OpenAPI({ description: 'Lists all information about the user whose id got provided.
Please remember that only permissions granted directly to the user will show up here, not permissions inherited from groups.' }) + @OpenAPI({ description: 'Lists all information about the user whose id got provided.
Please remember that all permissions granted to the user will show up here.' }) async getOne(@Param('id') id: number) { let user = await this.userRepository.findOne({ id: id }, { relations: ['permissions', 'groups', 'groups.permissions'] }) if (!user) { throw new UserNotFoundError(); } return new ResponseUser(user); } + @Get('/:id/permissions') + @Authorized("USER:GET") + @ResponseSchema(ResponseUser) + @ResponseSchema(UserNotFoundError, { statusCode: 404 }) + @OnUndefined(UserNotFoundError) + @OpenAPI({ description: 'Lists all permissions granted to the user sorted into directly granted and inherited as permission response objects.' }) + async getPermissions(@Param('id') id: number) { + let user = await this.userRepository.findOne({ id: id }, { relations: ['permissions', 'groups', 'groups.permissions', 'permissions.principal', 'groups.permissions.principal'] }) + if (!user) { throw new UserNotFoundError(); } + return new ResponseUserPermissions(user); + } + @Post() @Authorized("USER:CREATE") @ResponseSchema(ResponseUser) diff --git a/src/models/responses/ResponseUser.ts b/src/models/responses/ResponseUser.ts index 3da5434..526d537 100644 --- a/src/models/responses/ResponseUser.ts +++ b/src/models/responses/ResponseUser.ts @@ -70,6 +70,7 @@ export class ResponseUser extends ResponsePrincipal { /** * The user's permissions. + * Directly granted or inherited converted to their string form and deduplicated. */ @IsArray() @IsOptional() diff --git a/src/models/responses/ResponseUserPermissions.ts b/src/models/responses/ResponseUserPermissions.ts new file mode 100644 index 0000000..d5a8a7b --- /dev/null +++ b/src/models/responses/ResponseUserPermissions.ts @@ -0,0 +1,40 @@ +import { + IsArray, + + + IsOptional +} from "class-validator"; +import { User } from '../entities/User'; +import { ResponsePermission } from './ResponsePermission'; + +/** + * Defines the user permission response (get /api/users/:id/permissions). +*/ +export class ResponseUserPermissions { + /** + * The permissions directly granted to the user. + */ + @IsArray() + @IsOptional() + directlyGranted: ResponsePermission[] = new Array(); + + /** + * The permissions directly inherited the user. + */ + @IsArray() + @IsOptional() + inherited: ResponsePermission[] = new Array(); + + /** + * Creates a ResponseUserPermissions object from a user. + * @param user The user the response shall be build for. + */ + public constructor(user: User) { + for (let permission of user.permissions) { + this.directlyGranted.push(permission.toResponse()); + } + for (let permission of user.inheritedPermissions) { + this.inherited.push(permission.toResponse()); + } + } +}