From 05fc06e744c82bcc9bd35b73e009c4776768c36f Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Fri, 23 Jul 2021 20:23:34 +0300 Subject: [PATCH] seeders, migrations & roles --- .adonisrc.json | 4 +- .env.example | 4 + .prettierrc.json | 4 +- app/Controllers/Http/AuthController.ts | 55 +++++++++ app/Controllers/Http/ContactsController.ts | 4 + app/Controllers/Http/RolesController.ts | 83 +++++++++++++ app/Controllers/Http/UsersController.ts | 71 ++++++++--- app/Middleware/Auth.ts | 68 +++++++++++ app/Middleware/Role.ts | 40 +++++++ app/Models/Contact.ts | 31 +++++ app/Models/Role.ts | 20 ++++ app/Models/User.ts | 33 ++++-- app/Validators/AddRoleToUserValidator.ts | 12 ++ app/Validators/CreateUserValidator.ts | 13 +- app/Validators/UpdateUserValidator.ts | 20 +--- config/auth.ts | 112 ++++++++++++++++++ config/database.ts | 4 + config/redis.ts | 48 ++++++++ contracts/auth.ts | 72 +++++++++++ contracts/redis.ts | 12 ++ ...155087_users.ts => 1626545059989_users.ts} | 8 +- database/migrations/1626964029348_roles.ts | 19 +++ .../migrations/1626970448054_users_roles.ts | 18 +++ database/migrations/1627022639849_contacts.ts | 23 ++++ database/seeders/MainSeeder/Index.ts | 23 ++++ database/seeders/Role.ts | 22 ++++ database/seeders/User.ts | 56 +++++++++ env.ts | 4 + package-lock.json | 85 +++++++++++++ package.json | 1 + start/kernel.ts | 5 +- start/routes.ts | 19 ++- tsconfig.json | 4 +- 33 files changed, 933 insertions(+), 64 deletions(-) create mode 100644 app/Controllers/Http/AuthController.ts create mode 100644 app/Controllers/Http/ContactsController.ts create mode 100644 app/Controllers/Http/RolesController.ts create mode 100644 app/Middleware/Auth.ts create mode 100644 app/Middleware/Role.ts create mode 100644 app/Models/Contact.ts create mode 100644 app/Models/Role.ts create mode 100644 app/Validators/AddRoleToUserValidator.ts create mode 100644 config/auth.ts create mode 100644 config/redis.ts create mode 100644 contracts/auth.ts create mode 100644 contracts/redis.ts rename database/migrations/{1626044155087_users.ts => 1626545059989_users.ts} (66%) create mode 100644 database/migrations/1626964029348_roles.ts create mode 100644 database/migrations/1626970448054_users_roles.ts create mode 100644 database/migrations/1627022639849_contacts.ts create mode 100644 database/seeders/MainSeeder/Index.ts create mode 100644 database/seeders/Role.ts create mode 100644 database/seeders/User.ts diff --git a/.adonisrc.json b/.adonisrc.json index 5ad0b65..0b74065 100644 --- a/.adonisrc.json +++ b/.adonisrc.json @@ -20,7 +20,9 @@ "providers": [ "./providers/AppProvider", "@adonisjs/core", - "@adonisjs/lucid" + "@adonisjs/lucid", + "@adonisjs/auth", + "@adonisjs/redis" ], "aceProviders": [ "@adonisjs/repl" diff --git a/.env.example b/.env.example index 97f30d1..2682f22 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,7 @@ PG_PORT=5432 PG_USER=lucid PG_PASSWORD= PG_DB_NAME=lucid +REDIS_CONNECTION=local +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= diff --git a/.prettierrc.json b/.prettierrc.json index 2b4fbec..6c400d5 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -4,5 +4,7 @@ "tabWidth": 2, "semi": true, "jsxSingleQuote": true, - "arrowParens": "avoid" + "arrowParens": "avoid", + "endOfLine": "auto", + "printWidth": 100 } diff --git a/app/Controllers/Http/AuthController.ts b/app/Controllers/Http/AuthController.ts new file mode 100644 index 0000000..dbd62fe --- /dev/null +++ b/app/Controllers/Http/AuthController.ts @@ -0,0 +1,55 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import Logger from '@ioc:Adonis/Core/Logger'; +import Hash from '@ioc:Adonis/Core/Hash'; +import User from 'App/Models/User'; +import { Exception } from '@adonisjs/core/build/standalone'; + +export default class AuthController { + private user: typeof User; + + private guard: 'api'; + + constructor() { + this.user = User; + } + + /** + * Creates a new token for user and returns it. + * POST /login + * + * @param {HttpContextContract} context + * @returns {object} - Token data + */ + + public async login({ auth, request }: HttpContextContract) { + const login = request.input('login'); + const password = request.input('password'); + const user = await this.user.query().preload('roles').where('login', login).firstOrFail(); + + if (!(await Hash.verify(user.password, password))) { + throw new Exception('Invalid credentials.', 401, 'E_INVALID_CREDENTIALS'); + } + + const token = await auth.use(this.guard).generate(user, { + expiresIn: '1d', + userRoles: user.roles.map(role => role.slug), + }); + + Logger.info(`A token for user with id: "${token.user.id}" was successfully generated.`); + + return token.toJSON(); + } + + /** + * Revoke a token if user logged in. + * POST /logout + * + * @param {HttpContextContract} context + * @returns {object} - Revoked message + */ + + public async logout({ auth }: HttpContextContract) { + await auth.use(this.guard).revoke(); + return { message: 'REVOKED' }; + } +} diff --git a/app/Controllers/Http/ContactsController.ts b/app/Controllers/Http/ContactsController.ts new file mode 100644 index 0000000..6f63214 --- /dev/null +++ b/app/Controllers/Http/ContactsController.ts @@ -0,0 +1,4 @@ +// import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export default class ContactsController { +} diff --git a/app/Controllers/Http/RolesController.ts b/app/Controllers/Http/RolesController.ts new file mode 100644 index 0000000..8ce779c --- /dev/null +++ b/app/Controllers/Http/RolesController.ts @@ -0,0 +1,83 @@ +import { Exception } from '@adonisjs/core/build/standalone'; +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +import Role from 'App/Models/Role'; +import User from 'App/Models/User'; +import AddRoleToUserValidator from 'App/Validators/AddRoleToUserValidator'; + +export default class RolesController { + private readonly user: typeof User; + + private readonly role: typeof Role; + + constructor() { + this.user = User; + this.role = Role; + } + + private async findRolesBySlug(input: any) { + const roles = await this.role.query().whereIn('slug', input); + + if (roles.length !== input.length) { + throw new Exception( + 'Unable to find any role using input value "roles"', + 404, + 'E_ROLE_NOT_FOUND' + ); + } + + return roles; + } + + /** + * Add a role to a user by "id" + * POST /user/:id/roles + * + * @param {HttpContextContract} context + * @returns {Promise} - Updated user. + */ + public async store({ request, params }: HttpContextContract): Promise { + try { + await request.validate(AddRoleToUserValidator); + + const input = request.input('roles'); + const user = await this.user.findOrFail(params.id); + const roles = await this.findRolesBySlug(input); + + await user.related('roles').attach(roles.map(r => r.id)); + await user.load('roles'); + + return user.roles; + } catch (error) { + if (error.code === '23505') { + throw new Exception('Some role already attached to that user.', 400, 'E_ROLE_ATTACHED'); + } + throw error; + } + } + + /** + * Detach role from user by "id" + * DELETE /users/:id/roles + * + */ + public async destroy({ request, params }: HttpContextContract): Promise { + const input = request.input('roles'); + const user = await this.user.query().preload('roles').where('id', params.id).firstOrFail(); + + /** + * Detach all when "roles" input not provided + */ + if (input === undefined) { + user.related('roles').detach([]); + return []; + } + + const roles = await this.findRolesBySlug(input); + + user.related('roles').detach(roles.map(r => r.id)); + await user.load('roles'); + + return user.roles; + } +} diff --git a/app/Controllers/Http/UsersController.ts b/app/Controllers/Http/UsersController.ts index 847d563..bf6c555 100644 --- a/app/Controllers/Http/UsersController.ts +++ b/app/Controllers/Http/UsersController.ts @@ -1,13 +1,21 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import Role from 'App/Models/Role'; import User from 'App/Models/User'; +import Contact from 'App/Models/Contact'; import CreateUserValidator from 'App/Validators/CreateUserValidator'; import UpdateUserValidator from 'App/Validators/UpdateUserValidator'; export default class UsersController { - private readonly userModel: typeof User; + private readonly user: typeof User; + + private readonly role: typeof Role; + + private readonly contact: typeof Contact; constructor() { - this.userModel = User; + this.user = User; + this.role = Role; + this.contact = Contact; } /** @@ -18,7 +26,7 @@ export default class UsersController { */ public async index(): Promise { - const users = this.userModel.all(); + const users = await this.user.query().preload('contacts').preload('roles'); return users; } @@ -31,7 +39,11 @@ export default class UsersController { */ public async show({ params }: HttpContextContract): Promise { - const user = await this.userModel.findOrFail(params.user_id); + const user = await this.user.findOrFail(params.id); + + await user.load('contacts'); + await user.load('roles'); + return user; } @@ -45,21 +57,27 @@ export default class UsersController { public async store({ request }: HttpContextContract): Promise { await request.validate(CreateUserValidator); - const user = await this.userModel.create({ - name: request.input('name'), - surname: request.input('surname'), - patronymic: request.input('patronymic'), + + const roles = await this.role.query().whereIn('slug', request.input('roles')); + const user = await this.user.create({ + first_name: request.input('first_name'), + last_name: request.input('last_name'), login: request.input('login'), - email: request.input('email'), password: request.input('password'), }); + await user.related('contacts').create({ email: request.input('email') }); + await user.related('roles').attach(roles.map(r => r.id)); + + await user.load('roles'); + await user.load('contacts'); + return user; } /** * Update a user in a system by "id". - * PUT /users + * PATCH /users/:id * * @param {HttpContextContract} context * @returns {Promise} @@ -67,14 +85,35 @@ export default class UsersController { public async update({ request, params }: HttpContextContract): Promise { await request.validate(UpdateUserValidator); - const user = await this.userModel.findOrFail(params.id); - user.name = request.input('name'); - user.surname = request.input('surname'); - user.patronymic = request.input('patronymic'); + + /** + * User update + */ + const user = await this.user.findOrFail(params.id); + user.first_name = request.input('first_name'); + user.last_name = request.input('last_name'); user.login = request.input('login'); - user.email = request.input('email'); user.password = request.input('password'); + + // await user.load('roles'); + + /** + * Contacts update + */ + const contacts = await this.contact.findByOrFail('user_id', params.id); + contacts.email = request.input('email'); + + /** + * Save the updated data + */ await user.save(); + await contacts.save(); + + /** + * Load the updated data to return + */ + await user.load('roles'); + await user.load('contacts'); return user; } @@ -88,7 +127,7 @@ export default class UsersController { */ public async destroy({ params }: HttpContextContract): Promise { - const user = await this.userModel.findOrFail(params.id); + const user = await this.user.findOrFail(params.id); await user.delete(); return user; diff --git a/app/Middleware/Auth.ts b/app/Middleware/Auth.ts new file mode 100644 index 0000000..4ddbf60 --- /dev/null +++ b/app/Middleware/Auth.ts @@ -0,0 +1,68 @@ +import { GuardsList } from '@ioc:Adonis/Addons/Auth'; +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { AuthenticationException } from '@adonisjs/auth/build/standalone'; + +/** + * Auth middleware is meant to restrict un-authenticated access to a given route + * or a group of routes. + * + * You must register this middleware inside `start/kernel.ts` file under the list + * of named middleware. + */ +export default class AuthMiddleware { + /** + * The URL to redirect to when request is Unauthorized + */ + protected redirectTo = '/login'; + + /** + * Authenticates the current HTTP request against a custom set of defined + * guards. + * + * The authentication loop stops as soon as the user is authenticated using any + * of the mentioned guards and that guard will be used by the rest of the code + * during the current request. + */ + protected async authenticate(auth: HttpContextContract['auth'], guards: (keyof GuardsList)[]) { + /** + * Hold reference to the guard last attempted within the for loop. We pass + * the reference of the guard to the "AuthenticationException", so that + * it can decide the correct response behavior based upon the guard + * driver + */ + let guardLastAttempted: string | undefined; + const authPromises: Promise[] = guards.map(guard => auth.use(guard).check()); + const authResults: boolean[] = await Promise.all(authPromises); + + for (let i = 0; i < authResults.length; i += 1) { + /** + * User has been successfully authenticated + */ + if (authResults[i]) return; + } + + /** + * Unable to authenticate using any guard + */ + throw new AuthenticationException( + 'Unauthorized access', + 'E_UNAUTHORIZED_ACCESS', + guardLastAttempted, + this.redirectTo + ); + } + + public async handle( + { auth }: HttpContextContract, + next: () => Promise, + customGuards: (keyof GuardsList)[] + ) { + /** + * Uses the user defined guards or the default guard mentioned in + * the config file + */ + const guards = customGuards.length ? customGuards : [auth.name]; + await this.authenticate(auth, guards); + await next(); + } +} diff --git a/app/Middleware/Role.ts b/app/Middleware/Role.ts new file mode 100644 index 0000000..4844bf2 --- /dev/null +++ b/app/Middleware/Role.ts @@ -0,0 +1,40 @@ +import { Exception } from '@adonisjs/core/build/standalone'; +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class Role { + error: { + message: string; + status: number; + errorCode: string; + }; + + constructor() { + this.error = { + message: 'You dont have permission to perform that action.', + status: 403, + errorCode: 'E_ACCESS_DENIED', + }; + } + + /** + * Checks an array of roles for the current user + * If the user role is not found in the array of roles for the route, an error is thrown + * + * @param {string[]} roles - Array of roles for route + * @param {string[]} userRole - Array of roles of current user + */ + protected checkRoles(roles: string[], userRoles: string[]): void { + if (!roles.some(role => userRoles.includes(role))) { + /** + * Failed to verify user rights + */ + throw new Exception(this.error.message, this.error.status, this.error.errorCode); + } + } + + public async handle({ auth }: HttpContextContract, next: () => Promise, roles: string[]) { + const { userRoles } = auth.use('api').token?.meta; + this.checkRoles(roles, userRoles); + await next(); + } +} diff --git a/app/Models/Contact.ts b/app/Models/Contact.ts new file mode 100644 index 0000000..da2afe5 --- /dev/null +++ b/app/Models/Contact.ts @@ -0,0 +1,31 @@ +import { DateTime } from 'luxon'; +import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'; + +export default class Contact extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @column({ serializeAs: null }) + user_id: string; + + @column() + public email: string; + + @column() + public phone_number: string; + + @column() + public vk_id: string; + + @column() + public twitter_id: string; + + @column() + public telegram_id: string; + + @column.dateTime({ autoCreate: true, serializeAs: null }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null }) + public updatedAt: DateTime; +} diff --git a/app/Models/Role.ts b/app/Models/Role.ts new file mode 100644 index 0000000..241cf91 --- /dev/null +++ b/app/Models/Role.ts @@ -0,0 +1,20 @@ +/* eslint-disable import/no-cycle */ +import { DateTime } from 'luxon'; +import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'; + +export default class Role extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @column() + public name: string; + + @column() + public slug: string; + + @column.dateTime({ autoCreate: true, serializeAs: null }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null }) + public updatedAt: DateTime; +} diff --git a/app/Models/User.ts b/app/Models/User.ts index 2a07e08..7052067 100644 --- a/app/Models/User.ts +++ b/app/Models/User.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable no-param-reassign */ import { DateTime } from 'luxon'; import { @@ -5,36 +6,46 @@ import { beforeCreate, beforeSave, column, + HasOne, + hasOne, + ManyToMany, + manyToMany, } from '@ioc:Adonis/Lucid/Orm'; import Hash from '@ioc:Adonis/Core/Hash'; import { nanoid } from 'nanoid'; +import Role from './Role'; +import Contact from './Contact'; export default class User extends BaseModel { @column({ isPrimary: true }) public id: string; @column() - public name: string; + public first_name: string; @column() - public surname: string; + public last_name: string; - @column() - public patronymic: string; - - @column() + @column({ serializeAs: null }) public login: string; - @column() - public email: string; - @column({ serializeAs: null }) public password: string; - @column.dateTime({ autoCreate: true }) + @hasOne(() => Contact, { + foreignKey: 'user_id', + }) + public contacts: HasOne; + + @manyToMany(() => Role, { + pivotTable: 'users_roles', + }) + public roles: ManyToMany; + + @column.dateTime({ autoCreate: true, serializeAs: null }) public createdAt: DateTime; - @column.dateTime({ autoCreate: true, autoUpdate: true }) + @column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null }) public updatedAt: DateTime; @beforeCreate() diff --git a/app/Validators/AddRoleToUserValidator.ts b/app/Validators/AddRoleToUserValidator.ts new file mode 100644 index 0000000..1986975 --- /dev/null +++ b/app/Validators/AddRoleToUserValidator.ts @@ -0,0 +1,12 @@ +import { schema } from '@ioc:Adonis/Core/Validator'; +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class AddRoleToUserValidator { + constructor(protected ctx: HttpContextContract) {} + + public schema = schema.create({ + roles: schema.array().members(schema.string()), + }); + + public messages = {}; +} diff --git a/app/Validators/CreateUserValidator.ts b/app/Validators/CreateUserValidator.ts index 445c748..e0bcf1e 100644 --- a/app/Validators/CreateUserValidator.ts +++ b/app/Validators/CreateUserValidator.ts @@ -24,14 +24,13 @@ export default class CreateUserValidator { * ``` */ public schema = schema.create({ - name: schema.string(), - surname: schema.string(), - patronymic: schema.string(), - login: schema.string(), - email: schema.string({}, [ - rules.email(), - rules.unique({ table: 'users', column: 'email' }), + first_name: schema.string(), + last_name: schema.string(), + login: schema.string({}, [ + rules.maxLength(128), + rules.unique({ table: 'users', column: 'login' }), ]), + email: schema.string({}, [rules.email(), rules.unique({ table: 'contacts', column: 'email' })]), password: schema.string(), }); diff --git a/app/Validators/UpdateUserValidator.ts b/app/Validators/UpdateUserValidator.ts index a87423d..bfafb9b 100644 --- a/app/Validators/UpdateUserValidator.ts +++ b/app/Validators/UpdateUserValidator.ts @@ -5,27 +5,15 @@ export default class UpdateUserValidator { constructor(protected ctx: HttpContextContract) {} public schema = schema.create({ - name: schema.string.optional(), - surname: schema.string.optional(), - patronymic: schema.string.optional(), - login: schema.string.optional(), + first_name: schema.string.optional(), + last_name: schema.string.optional(), + login: schema.string.optional({}, [rules.unique({ table: 'users', column: 'login' })]), email: schema.string.optional({}, [ rules.email(), - rules.unique({ table: 'users', column: 'email' }), + rules.unique({ table: 'contacts', column: 'email' }), ]), password: schema.string.optional(), }); - /** - * Custom messages for validation failures. You can make use of dot notation `(.)` - * for targeting nested fields and array expressions `(*)` for targeting all - * children of an array. For example: - * - * { - * 'profile.username.required': 'Username is required', - * 'scores.*.number': 'Define scores as valid numbers' - * } - * - */ public messages = {}; } diff --git a/config/auth.ts b/config/auth.ts new file mode 100644 index 0000000..a9e2af9 --- /dev/null +++ b/config/auth.ts @@ -0,0 +1,112 @@ +/** + * Config source: https://git.io/JY0mp + * + * Feel free to let us know via PR, if you find something broken in this config + * file. + */ + +import { AuthConfig } from '@ioc:Adonis/Addons/Auth'; + +/* +|-------------------------------------------------------------------------- +| Authentication Mapping +|-------------------------------------------------------------------------- +| +| List of available authentication mapping. You must first define them +| inside the `contracts/auth.ts` file before mentioning them here. +| +*/ +const authConfig: AuthConfig = { + guard: 'api', + guards: { + /* + |-------------------------------------------------------------------------- + | OAT Guard + |-------------------------------------------------------------------------- + | + | OAT (Opaque access tokens) guard uses database backed tokens to authenticate + | HTTP request. This guard DOES NOT rely on sessions or cookies and uses + | Authorization header value for authentication. + | + | Use this guard to authenticate mobile apps or web clients that cannot rely + | on cookies/sessions. + | + */ + api: { + driver: 'oat', + + /* + |-------------------------------------------------------------------------- + | Redis provider for managing tokens + |-------------------------------------------------------------------------- + | + | Uses Redis for managing tokens. We recommend using the "redis" driver + | over the "database" driver when the tokens based auth is the + | primary authentication mode. + | + | Redis ensure that all the expired tokens gets cleaned up automatically. + | Whereas with SQL, you have to cleanup expired tokens manually. + | + | The foreignKey column is used to make the relationship between the user + | and the token. You are free to use any column name here. + | + */ + tokenProvider: { + type: 'api', + driver: 'redis', + redisConnection: 'local', + foreignKey: 'user_id', + }, + + provider: { + /* + |-------------------------------------------------------------------------- + | Driver + |-------------------------------------------------------------------------- + | + | Name of the driver + | + */ + driver: 'lucid', + + /* + |-------------------------------------------------------------------------- + | Identifier key + |-------------------------------------------------------------------------- + | + | The identifier key is the unique key on the model. In most cases specifying + | the primary key is the right choice. + | + */ + identifierKey: 'id', + + /* + |-------------------------------------------------------------------------- + | Uids + |-------------------------------------------------------------------------- + | + | Uids are used to search a user against one of the mentioned columns. During + | login, the auth module will search the user mentioned value against one + | of the mentioned columns to find their user record. + | + */ + uids: ['email'], + + /* + |-------------------------------------------------------------------------- + | Model + |-------------------------------------------------------------------------- + | + | The model to use for fetching or finding users. The model is imported + | lazily since the config files are read way earlier in the lifecycle + | of booting the app and the models may not be in a usable state at + | that time. + | + */ + model: () => import('App/Models/User'), + }, + }, + }, +}; + +export default authConfig; diff --git a/config/database.ts b/config/database.ts index 467f4f9..6ca9598 100644 --- a/config/database.ts +++ b/config/database.ts @@ -44,6 +44,10 @@ const databaseConfig: DatabaseConfig = { }, migrations: { naturalSort: true, + tableName: 'migrations', + }, + seeders: { + paths: ['./database/seeders/MainSeeder'], }, healthCheck: true, debug: true, diff --git a/config/redis.ts b/config/redis.ts new file mode 100644 index 0000000..a3998ee --- /dev/null +++ b/config/redis.ts @@ -0,0 +1,48 @@ +/** + * Config source: https://git.io/JemcF + * + * Feel free to let us know via PR, if you find something broken in this config + * file. + */ + +import Env from '@ioc:Adonis/Core/Env'; +import { RedisConfig } from '@ioc:Adonis/Addons/Redis'; + +/* +|-------------------------------------------------------------------------- +| Redis configuration +|-------------------------------------------------------------------------- +| +| Following is the configuration used by the Redis provider to connect to +| the redis server and execute redis commands. +| +| Do make sure to pre-define the connections type inside `contracts/redis.ts` +| file for AdonisJs to recognize connections. +| +| Make sure to check `contracts/redis.ts` file for defining extra connections +*/ +const redisConfig: RedisConfig = { + connection: Env.get('REDIS_CONNECTION'), + + connections: { + /* + |-------------------------------------------------------------------------- + | The default connection + |-------------------------------------------------------------------------- + | + | The main connection you want to use to execute redis commands. The same + | connection will be used by the session provider, if you rely on the + | redis driver. + | + */ + local: { + host: Env.get('REDIS_HOST'), + port: Env.get('REDIS_PORT'), + password: Env.get('REDIS_PASSWORD', ''), + db: 0, + keyPrefix: '', + }, + }, +}; + +export default redisConfig; diff --git a/contracts/auth.ts b/contracts/auth.ts new file mode 100644 index 0000000..90916e2 --- /dev/null +++ b/contracts/auth.ts @@ -0,0 +1,72 @@ +/** + * Contract source: https://git.io/JOdz5 + * + * Feel free to let us know via PR, if you find something broken in this + * file. + */ + +import User from 'App/Models/User' + +declare module '@ioc:Adonis/Addons/Auth' { + /* + |-------------------------------------------------------------------------- + | Providers + |-------------------------------------------------------------------------- + | + | The providers are used to fetch users. The Auth module comes pre-bundled + | with two providers that are `Lucid` and `Database`. Both uses database + | to fetch user details. + | + | You can also create and register your own custom providers. + | + */ + interface ProvidersList { + /* + |-------------------------------------------------------------------------- + | User Provider + |-------------------------------------------------------------------------- + | + | The following provider uses Lucid models as a driver for fetching user + | details from the database for authentication. + | + | You can create multiple providers using the same underlying driver with + | different Lucid models. + | + */ + user: { + implementation: LucidProviderContract, + config: LucidProviderConfig, + }, + } + + /* + |-------------------------------------------------------------------------- + | Guards + |-------------------------------------------------------------------------- + | + | The guards are used for authenticating users using different drivers. + | The auth module comes with 3 different guards. + | + | - SessionGuardContract + | - BasicAuthGuardContract + | - OATGuardContract ( Opaque access token ) + | + | Every guard needs a provider for looking up users from the database. + | + */ + interface GuardsList { + /* + |-------------------------------------------------------------------------- + | OAT Guard + |-------------------------------------------------------------------------- + | + | OAT, stands for (Opaque access tokens) guard uses database backed tokens + | to authenticate requests. + | + */ + api: { + implementation: OATGuardContract<'user', 'api'>, + config: OATGuardConfig<'user'>, + }, + } +} diff --git a/contracts/redis.ts b/contracts/redis.ts new file mode 100644 index 0000000..1f2a08c --- /dev/null +++ b/contracts/redis.ts @@ -0,0 +1,12 @@ +/** + * Contract source: https://git.io/JemcN + * + * Feel free to let us know via PR, if you find something broken in this config + * file. + */ + +declare module '@ioc:Adonis/Addons/Redis' { + interface RedisConnectionsList { + local: RedisConnectionConfig; + } +} diff --git a/database/migrations/1626044155087_users.ts b/database/migrations/1626545059989_users.ts similarity index 66% rename from database/migrations/1626044155087_users.ts rename to database/migrations/1626545059989_users.ts index addb6e5..cee8469 100644 --- a/database/migrations/1626044155087_users.ts +++ b/database/migrations/1626545059989_users.ts @@ -5,12 +5,10 @@ export default class Users extends BaseSchema { public async up() { this.schema.createTable(this.tableName, table => { - table.string('id', 21).primary().notNullable(); - table.string('name', 24).notNullable(); - table.string('surname', 24).notNullable(); - table.string('patronymic', 24).notNullable(); + table.string('id', 21).primary(); + table.string('first_name', 24).notNullable(); + table.string('last_name', 24).notNullable(); table.string('login', 128).notNullable().unique(); - table.string('email', 254).notNullable().unique(); table.string('password').notNullable(); table.timestamp('created_at', { useTz: true }); table.timestamp('updated_at', { useTz: true }); diff --git a/database/migrations/1626964029348_roles.ts b/database/migrations/1626964029348_roles.ts new file mode 100644 index 0000000..0ccec49 --- /dev/null +++ b/database/migrations/1626964029348_roles.ts @@ -0,0 +1,19 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; + +export default class Roles extends BaseSchema { + protected tableName = 'roles'; + + public async up() { + this.schema.createTable(this.tableName, table => { + table.increments('id'); + table.string('name'); + table.string('slug'); + table.timestamp('created_at', { useTz: true }); + table.timestamp('updated_at', { useTz: true }); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/database/migrations/1626970448054_users_roles.ts b/database/migrations/1626970448054_users_roles.ts new file mode 100644 index 0000000..a95c131 --- /dev/null +++ b/database/migrations/1626970448054_users_roles.ts @@ -0,0 +1,18 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; + +export default class UsersRoles extends BaseSchema { + protected tableName = 'users_roles'; + + public async up() { + this.schema.createTable(this.tableName, table => { + table.increments('id'); + table.string('user_id', 21).unsigned().references('users.id').onDelete('CASCADE'); + table.integer('role_id').unsigned().references('roles.id').onDelete('CASCADE'); + table.unique(['user_id', 'role_id']); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/database/migrations/1627022639849_contacts.ts b/database/migrations/1627022639849_contacts.ts new file mode 100644 index 0000000..ecd718a --- /dev/null +++ b/database/migrations/1627022639849_contacts.ts @@ -0,0 +1,23 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; + +export default class Contacts extends BaseSchema { + protected tableName = 'contacts'; + + public async up() { + this.schema.createTable(this.tableName, table => { + table.increments('id'); + table.string('user_id', 21).unsigned().references('users.id').onDelete('CASCADE'); + table.string('email').unique().notNullable(); + table.string('phone_number').unique(); + table.string('vk_id').unique(); + table.string('twitter_id').unique(); + table.string('telegram_id').unique(); + table.timestamp('created_at', { useTz: true }); + table.timestamp('updated_at', { useTz: true }); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/database/seeders/MainSeeder/Index.ts b/database/seeders/MainSeeder/Index.ts new file mode 100644 index 0000000..87157f4 --- /dev/null +++ b/database/seeders/MainSeeder/Index.ts @@ -0,0 +1,23 @@ +import BaseSeeder from '@ioc:Adonis/Lucid/Seeder'; +import Application from '@ioc:Adonis/Core/Application'; + +export default class IndexSeeder extends BaseSeeder { + private async runSeeder(seeder: { default: typeof BaseSeeder }) { + const Seeder = seeder.default; + + /** + * Do not run when not in dev mode and seeder is development + * only + */ + if (Seeder.developmentOnly && !Application.inDev) { + return; + } + + await new Seeder(this.client).run(); + } + + public async run() { + await this.runSeeder(await import('../Role')); + await this.runSeeder(await import('../User')); + } +} diff --git a/database/seeders/Role.ts b/database/seeders/Role.ts new file mode 100644 index 0000000..dcde9ba --- /dev/null +++ b/database/seeders/Role.ts @@ -0,0 +1,22 @@ +/* eslint-disable class-methods-use-this */ +import BaseSeeder from '@ioc:Adonis/Lucid/Seeder'; +import Role from 'App/Models/Role'; + +export default class RoleSeeder extends BaseSeeder { + public async run() { + await Role.createMany([ + { + name: 'Administrator', + slug: 'admin', + }, + { + name: 'Teacher', + slug: 'teacher', + }, + { + name: 'Student', + slug: 'student', + }, + ]); + } +} diff --git a/database/seeders/User.ts b/database/seeders/User.ts new file mode 100644 index 0000000..3dd2a66 --- /dev/null +++ b/database/seeders/User.ts @@ -0,0 +1,56 @@ +/* eslint-disable class-methods-use-this */ +import BaseSeeder from '@ioc:Adonis/Lucid/Seeder'; +import Role from 'App/Models/Role'; +import User from 'App/Models/User'; + +export default class UserSeeder extends BaseSeeder { + public async run() { + /** + * Roles + */ + const roles = { + admin: await Role.findByOrFail('slug', 'admin'), + teacher: await Role.findByOrFail('slug', 'teacher'), + student: await Role.findByOrFail('slug', 'student'), + }; + + /** + * Administrator + */ + const user1 = new User(); + user1.first_name = 'Ivan'; + user1.last_name = 'Ivanov'; + user1.login = 'ivan123'; + user1.password = 'ivan123'; + + await user1.save(); + await user1.related('roles').attach([roles.admin.id]); + await user1.related('contacts').create({ email: 'ivan_ivanov@ya.ru' }); + + /** + * Teacher + */ + const user2 = new User(); + user2.first_name = 'Nikolai'; + user2.last_name = 'Nikolaev'; + user2.login = 'nikolai123'; + user2.password = 'nikolai123'; + + await user2.save(); + await user2.related('roles').attach([roles.teacher.id]); + await user2.related('contacts').create({ email: 'nikolai_niko123@ya.ru' }); + + /** + * Student + */ + const user3 = new User(); + user3.first_name = 'Oleg'; + user3.last_name = 'Olegov'; + user3.login = 'oleg123'; + user3.password = 'oleg123'; + + await user3.save(); + await user3.related('roles').attach([roles.student.id]); + await user3.related('contacts').create({ email: 'oleg_olegov123@ya.ru' }); + } +} diff --git a/env.ts b/env.ts index 1d6c7fb..217972f 100644 --- a/env.ts +++ b/env.ts @@ -25,4 +25,8 @@ export default Env.rules({ PG_USER: Env.schema.string(), PG_PASSWORD: Env.schema.string.optional(), PG_DB_NAME: Env.schema.string(), + REDIS_CONNECTION: Env.schema.enum(['local'] as const), + REDIS_HOST: Env.schema.string({ format: 'host' }), + REDIS_PORT: Env.schema.number(), + REDIS_PASSWORD: Env.schema.string.optional(), }); diff --git a/package-lock.json b/package-lock.json index 96867f7..509ea40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -237,6 +237,16 @@ "jest-worker": "^27.0.6" } }, + "@adonisjs/redis": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@adonisjs/redis/-/redis-7.0.7.tgz", + "integrity": "sha512-M9nn2uaE8IJ25mMkoPQtrsvfjewjZSfOr2F2RzySDq/ymoOQ5RcBDrgZ40E/JUqPqDOV5zmU2E0ClEwujQFPmg==", + "requires": { + "@poppinss/utils": "^3.1.3", + "@types/ioredis": "^4.26.4", + "ioredis": "^4.27.6" + } + }, "@adonisjs/repl": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@adonisjs/repl/-/repl-3.1.4.tgz", @@ -668,6 +678,14 @@ "resolved": "https://registry.npmjs.org/@types/he/-/he-1.1.2.tgz", "integrity": "sha512-kSJPcLO1x+oolc0R89pUl2kozldQ/fVQ1C1p5mp8fPoLdF/ZcBvckaTC2M8xXh3GYendXvCpy5m/a2eSbfgNgw==" }, + "@types/ioredis": { + "version": "4.26.5", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.5.tgz", + "integrity": "sha512-aP/SUSFShzoVYcHHKKEq7+kQ5YaDsMRe64rcHCp+w4Unkfw2tico1vA6siPnMDe7+jIxuZRU/h8km6uZjEtswg==", + "requires": { + "@types/node": "*" + } + }, "@types/json-schema": { "version": "7.0.8", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.8.tgz", @@ -1643,6 +1661,11 @@ "string-width": "^4.2.0" } }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, "co-compose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/co-compose/-/co-compose-6.1.3.tgz", @@ -1938,6 +1961,11 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, + "denque": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", + "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -3381,6 +3409,30 @@ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==" }, + "ioredis": { + "version": "4.27.6", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.6.tgz", + "integrity": "sha512-6W3ZHMbpCa8ByMyC1LJGOi7P2WiOKP9B3resoZOVLDhi+6dDBOW+KNsRq3yI36Hmnb2sifCxHX+YSarTeXh48A==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "p-map": "^2.1.0", + "redis-commands": "1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==" + } + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3835,6 +3887,16 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5266,6 +5328,24 @@ "esprima": "~4.0.0" } }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -5824,6 +5904,11 @@ "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", "dev": true }, + "standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", diff --git a/package.json b/package.json index 936c0f9..6d670d6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@adonisjs/auth": "^8.0.7", "@adonisjs/core": "^5.1.9", "@adonisjs/lucid": "^15.0.3", + "@adonisjs/redis": "^7.0.7", "@adonisjs/repl": "^3.1.4", "luxon": "^1.28.0", "nanoid": "^3.1.23", diff --git a/start/kernel.ts b/start/kernel.ts index 779902d..538d2e9 100644 --- a/start/kernel.ts +++ b/start/kernel.ts @@ -38,4 +38,7 @@ Server.middleware.register([() => import('@ioc:Adonis/Core/BodyParser')]); | Route.get('dashboard', 'UserController.dashboard').middleware('auth') | */ -Server.middleware.registerNamed({}); +Server.middleware.registerNamed({ + auth: () => import('App/Middleware/Auth'), + role: () => import('App/Middleware/Role'), +}); diff --git a/start/routes.ts b/start/routes.ts index 025ffed..483ce18 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -23,9 +23,18 @@ import Route from '@ioc:Adonis/Core/Route'; Route.get('/', () => `API REST Server.`); Route.group(() => { - Route.resource('users', 'UsersController').apiOnly(); - // Route.get('/users', 'UsersController.index'); - // Route.get('/users/:id', 'UsersController.show'); - // Route.post('/users', 'UsersController.store'); - // Route.put('/users/:id', 'UsersController.update'); + /* Auth */ + Route.post('login', 'AuthController.login'); + Route.post('logout', 'AuthController.logout').middleware('auth'); + + /* API */ + Route.group(() => { + Route.get('users', 'UsersController.index').middleware('role:admin,teacher,student'); + Route.get('users/:id', 'UsersController.show').middleware('role:admin,teacher,student'); + Route.post('users', 'UsersController.store').middleware('role:admin'); + Route.patch('users/:id', 'UsersController.update').middleware('role:admin'); + Route.delete('users', 'UsersController.destroy').middleware('role:admin'); + Route.post('users/:id/roles', 'RolesController.store').middleware('role:admin'); + Route.delete('users/:id/roles', 'RolesController.destroy').middleware('role:admin'); + }).middleware('auth'); }).prefix('api'); diff --git a/tsconfig.json b/tsconfig.json index 676779d..78177af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,9 @@ "types": [ "@adonisjs/core", "@adonisjs/repl", - "@adonisjs/lucid" + "@adonisjs/lucid", + "@adonisjs/auth", + "@adonisjs/redis" ] } }