diff --git a/.github/workflows/continuous-deployment.yml b/.github/workflows/continuous-deployment.yml index 6fa2fec..055d20f 100644 --- a/.github/workflows/continuous-deployment.yml +++ b/.github/workflows/continuous-deployment.yml @@ -21,15 +21,23 @@ jobs: - name: Set deployment variables run: | if [ "${{ github.ref }}" = "refs/heads/dev" ]; then - echo "EXTPORT=3002" >> $GITHUB_ENV echo "IMAGE=backend-dev" >> $GITHUB_ENV - echo "VAULT_PORT=8202" >> $GITHUB_ENV - echo "SQLDATABASE=selecro_dev" >> $GITHUB_ENV + echo "DEFAULT_PORT=${{ secrets.DEFAULT_PORT_DEV }}" >> $GITHUB_ENV + echo "SQL_DATABASE=${{ secrets.SQL_DATABASE_DEV }}" >> $GITHUB_ENV + echo "VAULT_PORT=${{ secrets.VAULT_PORT_DEV }}" >> $GITHUB_ENV + echo "UNSEAL_KEY_1=${{ secrets.UNSEAL_KEY_1_DEV }}" >> $GITHUB_ENV + echo "UNSEAL_KEY_2=${{ secrets.UNSEAL_KEY_2_DEV }}" >> $GITHUB_ENV + echo "UNSEAL_KEY_3=${{ secrets.UNSEAL_KEY_3_DEV }}" >> $GITHUB_ENV + echo "ROOT_VAULT_TOKEN=${{ secrets.ROOT_VAULT_TOKEN_DEV }}" >> $GITHUB_ENV elif [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "EXTPORT=3001" >> $GITHUB_ENV echo "IMAGE=backend-main" >> $GITHUB_ENV - echo "VAULT_PORT=8201" >> $GITHUB_ENV - echo "SQLDATABASE=selecro_main" >> $GITHUB_ENV + echo "DEFAULT_PORT=${{ secrets.DEFAULT_PORT_MAIN }}" >> $GITHUB_ENV + echo "SQL_DATABASE=${{ secrets.SQL_DATABASE_MAIN }}" >> $GITHUB_ENV + echo "VAULT_PORT=${{ secrets.VAULT_PORT_MAIN }}" >> $GITHUB_ENV + echo "UNSEAL_KEY_1=${{ secrets.UNSEAL_KEY_1_MAIN }}" >> $GITHUB_ENV + echo "UNSEAL_KEY_2=${{ secrets.UNSEAL_KEY_2_MAIN }}" >> $GITHUB_ENV + echo "UNSEAL_KEY_3=${{ secrets.UNSEAL_KEY_3_MAIN }}" >> $GITHUB_ENV + echo "ROOT_VAULT_TOKEN=${{ secrets.ROOT_VAULT_TOKEN_MAIN }}" >> $GITHUB_ENV else echo "Invalid branch for deployment" && exit 1 fi @@ -78,28 +86,31 @@ jobs: docker ps -a | grep ${{ env.IMAGE }} && docker stop ${{ env.IMAGE }} || true && \ docker ps -a | grep ${{ env.IMAGE }} && docker rm ${{ env.IMAGE }} || true && \ docker run \ - -e HOST="${{ secrets.HOST }}" \ - -e SQLHOST="${{ secrets.SQLHOST }}" \ - -e SQLPORT="${{ secrets.SQLPORT }}" \ - -e SQLUSER="${{ secrets.SQLUSER }}" \ - -e SQLPASSWORD="${{ secrets.SQLPASSWORD }}" \ - -e SQLDATABASE="${{ env.SQLDATABASE }}" \ - -e TOKEN="${{ secrets.TOKEN }}" \ - -e EXTPORT="${{ env.EXTPORT }}" \ - -e EMAILHOST="${{ secrets.EMAILHOST }}" \ - -e EMAILPORT="${{ secrets.EMAILPORT }}" \ - -e EMAILUSER="${{ secrets.EMAILUSER }}" \ - -e EMAILPASSWORD="${{ secrets.EMAILPASSWORD }}" \ - -e JWT_SECRET="$${{ secrets.JWT_SECRET }}" \ - -e UNSEAL_KEY_1="${{ secrets.UNSEAL_KEY_1 }}" \ - -e UNSEAL_KEY_2="${{ secrets.UNSEAL_KEY_2 }}" \ + -e DEFAULT_HOST="${{ secrets.DEFAULT_HOST }}" \ + -e DEFAULT_PORT="${{ env.DEFAULT_PORT }}" \ + -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ + -e JWT_SECRET_EMAIL="${{ secrets.JWT_SECRET_EMAIL }}" \ + -e JWT_SECRET_SIGNUP="${{ secrets.JWT_SECRET_SIGNUP }}" \ + -e SQL_HOST="${{ secrets.SQL_HOST }}" \ + -e SQL_PORT="${{ secrets.SQL_PORT }}" \ + -e SQL_USER="${{ secrets.SQL_USER }}" \ + -e SQL_PASSWORD="${{ secrets.SQL_PASSWORD }}" \ + -e SQL_DATABASE="${{ env.SQL_DATABASE }}" \ + -e EMAIL_HOST="${{ secrets.EMAIL_HOST }}" \ + -e EMAIL_PORT="${{ secrets.EMAIL_PORT }}" \ + -e EMAIL_USER="${{ secrets.EMAIL_USER }}" \ + -e EMAIL_PASSWORD="${{ secrets.EMAIL_PASSWORD }}" \ + -e VAULT_URL="${{ secrets.VAULT_URL }}" \ -e VAULT_URL="${{ secrets.VAULT_URL }}" \ -e VAULT_PORT="${{ env.VAULT_PORT }}" \ - -e CLIENT_ID="${{ secrets.CLIENT_ID }}" \ - -e ROOT_VAULT="${{ secrets.ROOT_VAULT }}" \ - -e INSTRUCTION_KEY="${{ secrets.INSTRUCTION_KEY }}" \ - -e INSTRUCTION_KEY_PERMISSIONS="${{ secrets.INSTRUCTION_KEY_PERMISSIONS }}" \ - --name ${{ env.IMAGE }} -dp ${{ env.EXTPORT }}:${{ env.EXTPORT }} \ + -e UNSEAL_KEY_1="${{ env.UNSEAL_KEY_1 }}" \ + -e UNSEAL_KEY_2="${{ env.UNSEAL_KEY_2 }}" \ + -e UNSEAL_KEY_3="${{ env.UNSEAL_KEY_3 }}" \ + -e ROOT_VAULT_TOKEN="${{ env.ROOT_VAULT_TOKEN }}" \ + -e IMGUR_CLIENT_ID="${{ secrets.IMGUR_CLIENT_ID }}" \ + -e INSTRUCTION_KEY_PREMIUM="${{ secrets.INSTRUCTION_KEY_PREMIUM }}" \ + -e INSTRUCTION_KEY_PREMIUM_PERMISSIONS="${{ secrets.INSTRUCTION_KEY_PREMIUM_PERMISSIONS }}" \ + --name ${{ env.IMAGE }} -dp ${{ env.DEFAULT_PORT }}:${{ env.DEFAULT_PORT }} \ selecro/${{ env.IMAGE }}:${{ github.ref_name }}-${{ env.SHORT_SHA }} && \ docker update --restart unless-stopped ${{ env.IMAGE }} && exit diff --git a/src/application.ts b/src/application.ts index 26ad0de..92b207d 100644 --- a/src/application.ts +++ b/src/application.ts @@ -11,10 +11,18 @@ import { import {ServiceMixin} from '@loopback/service-proxy'; import * as dotenv from 'dotenv'; import path from 'path'; -import {PingController, UserController} from './controllers'; +import { + InstructionStepController, + PingController, + UserController, + UserInstructionController, + UserLinkController, + UserProgressController, +} from './controllers'; import {DbDataSource} from './datasources'; import { InstructionRepository, + ProgressRepository, StepRepository, UserLinkRepository, UserRepository, @@ -49,16 +57,20 @@ export class SelecroBackendApplication extends BootMixin( this.component(JWTAuthenticationComponent); this.controller(PingController); this.controller(UserController); - this.controller(StepRepository); + this.controller(UserProgressController); + this.controller(UserLinkController); + this.controller(UserInstructionController); + this.controller(InstructionStepController); this.repository(UserRepository); this.repository(InstructionRepository); this.repository(StepRepository); this.repository(UserLinkRepository); + this.repository(ProgressRepository); this.dataSource(DbDataSource); this.bind('services.jwt.service').toClass(JWTService); this.bind('authentication.jwt.expiresIn').to('32d'); - this.bind('authentication.jwt.secret').to(process.env.TOKEN); + this.bind('authentication.jwt.secret').to(process.env.JWT_SECRET); this.bind('services.hasher').toClass(BcryptHasher); this.bind('services.hasher.rounds').to(10); this.bind('services.user.service').toClass(MyUserService); diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 04c34f5..8b5353a 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -3,3 +3,4 @@ export * from './ping.controller'; export * from './user-instruction.controller'; export * from './user.controller'; export * from './user-link.controller'; +export * from './user-progress.controller'; diff --git a/src/controllers/instruction-step.controller.ts b/src/controllers/instruction-step.controller.ts index b17e286..8afe5c5 100644 --- a/src/controllers/instruction-step.controller.ts +++ b/src/controllers/instruction-step.controller.ts @@ -48,8 +48,8 @@ export class InstructionStepController { }, }, }) - async create( - @param.path.number('id') instructionId: number, + async createStep( + @param.path.number('instructionId') instructionId: number, @requestBody({ content: { 'application/json': { @@ -76,7 +76,7 @@ export class InstructionStepController { }, }, }) - step: Omit, + step: Omit, ): Promise { const user = await this.userRepository.findById(this.user.id); if (!user) { @@ -110,9 +110,9 @@ export class InstructionStepController { }, }, }) - async patch( - @param.path.number('stepId') stepId: number, + async patchStep( @param.path.number('instructionId') instructionId: number, + @param.path.number('stepId') stepId: number, @requestBody({ content: { 'application/json': { @@ -160,7 +160,7 @@ export class InstructionStepController { } @authenticate('jwt') - @del('/users/{id}/instructions/{instructionId}/steps/{stepId}', { + @del('/users/{id}/instructions/{instructionId}/steps/${stepId}', { responses: { '200': { description: 'Delete Step', @@ -174,9 +174,9 @@ export class InstructionStepController { }, }, }) - async delete( - @param.path.number('stepId') stepId: number, + async deleteStep( @param.path.number('instructionId') instructionId: number, + @param.path.number('stepId') stepId: number, ): Promise { const user = await this.userRepository.findById(this.user.id); if (!user) { @@ -239,7 +239,7 @@ export class InstructionStepController { }, }, }) - async getPublicInstructions( + async getSteps( @param.path.number('instructionId') instructionId: number, ): Promise[]> { const user = await this.userRepository.findById(this.user.id); @@ -274,9 +274,9 @@ export class InstructionStepController { }, }, }) - async uploadPicture( - @param.path.number('stepId') stepId: number, + async uploadStepsPicture( @param.path.number('instructionId') instructionId: number, + @param.path.number('stepId') stepId: number, @requestBody({ content: { 'multipart/form-data': { @@ -335,9 +335,9 @@ export class InstructionStepController { }, }, }) - async deleteImage( - @param.path.number('stepId') stepId: number, + async deleteStepPicture( @param.path.number('instructionId') instructionId: number, + @param.path.number('stepId') stepId: number, ): Promise { const user = await this.userRepository.findById(this.user.id); if (!user) { diff --git a/src/controllers/user-instruction.controller.ts b/src/controllers/user-instruction.controller.ts index 512e780..ae4f29e 100644 --- a/src/controllers/user-instruction.controller.ts +++ b/src/controllers/user-instruction.controller.ts @@ -15,9 +15,10 @@ import { } from '@loopback/rest'; import {SecurityBindings, UserProfile} from '@loopback/security'; import * as dotenv from 'dotenv'; -import {Difficulty, Instruction} from '../models'; +import {Difficulty, Instruction, Progress, ProgressRelations} from '../models'; import { InstructionRepository, + ProgressRepository, StepRepository, UserRepository, } from '../repositories'; @@ -38,6 +39,8 @@ export class UserInstructionController { @repository(InstructionRepository) protected instructionRepository: InstructionRepository, @repository(StepRepository) public stepRepository: StepRepository, + @repository(UserRepository) + protected progressRepository: ProgressRepository, ) { } @authenticate('jwt') @@ -55,7 +58,7 @@ export class UserInstructionController { }, }, }) - async create( + async createInstruction( @requestBody({ content: { 'application/json': { @@ -93,7 +96,7 @@ export class UserInstructionController { if (!key) { throw new HttpErrors.Unauthorized('Key not providen'); } - const instructionKey = process.env.INSTRUCTION_KEY ?? ''; + const instructionKey = process.env.INSTRUCTION_KEY_PREMIUM ?? ''; const keyMatch = await this.hasher.comparePassword(key, instructionKey); if (!keyMatch) { throw new HttpErrors.Unauthorized('Invalid password'); @@ -121,7 +124,7 @@ export class UserInstructionController { }, }, }) - async patch( + async patchInstruction( @param.path.number('instructionId') instructionId: number, @requestBody({ content: { @@ -178,8 +181,8 @@ export class UserInstructionController { }, }, }) - async delete( - @param.query.number('instructionId') instructionId: number, + async deleteInstruction( + @param.path.number('instructionId') instructionId: number, ): Promise { const userOriginal = await this.userRepository.findById(this.user.id); if (!userOriginal) { @@ -276,14 +279,15 @@ export class UserInstructionController { }, }, }) - async getUsersInstructions(): Promise< - Omit[] - > { + async getUsersInstructions(): Promise<{ + instructions: Omit[]; + progress: (Progress & ProgressRelations)[]; + }> { const user = await this.userRepository.findById(this.user.id); if (!user) { throw new HttpErrors.NotFound('User not found'); } - const data = await this.instructionRepository.find({ + const instructions = await this.instructionRepository.find({ where: { userId: this.user.id, }, @@ -302,7 +306,12 @@ export class UserInstructionController { premiumUserIds: false, }, }); - return data; + const progress = await this.progressRepository.find({ + where: { + userId: this.user.id, + }, + }); + return {instructions, progress}; } @authenticate('jwt') @@ -320,7 +329,7 @@ export class UserInstructionController { }, }, }) - async uploadPicture( + async uploadInstructionPicture( @param.path.number('instructionId') instructionId: number, @requestBody({ content: { @@ -375,7 +384,7 @@ export class UserInstructionController { }, }, }) - async deleteImage( + async deleteInstructionPicture( @param.path.number('instructionId') instructionId: number, ): Promise { const user = await this.userRepository.findById(this.user.id); @@ -414,12 +423,25 @@ export class UserInstructionController { }, }, }) - async setPremium( + async setPremiumInstruction( @param.path.number('instructionId') instructionId: number, - @requestBody() + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + key: {type: 'string'}, + premium: {type: 'boolean'}, + }, + required: ['key', 'premium'], + }, + }, + }, + }) request: { - premium: boolean; key: string; + premium: boolean; }, ): Promise { const user = await this.userRepository.findById(this.user.id); @@ -431,7 +453,7 @@ export class UserInstructionController { if (!instruction) { throw new HttpErrors.NotFound('Instruction not found'); } - const instructionKey = process.env.INSTRUCTION_KEY ?? ''; + const instructionKey = process.env.INSTRUCTION_KEY_PREMIUM ?? ''; const keyMatch = await this.hasher.comparePassword( request.key, instructionKey, @@ -457,6 +479,68 @@ export class UserInstructionController { return true; } + @authenticate('jwt') + @patch('/authorizate-for-premium-instruction/{instructionId}/{userId}', { + responses: { + '200': { + description: 'Authorize user for premium instructions', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) + async authorizeUserToPremiumInstruction( + @param.path.number('instructionId') instructionId: number, + @param.path.number('userId') userId: number, + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + key: {type: 'string'}, + }, + required: ['key'], + }, + }, + }, + }) + request: { + key: string; + }, + ): Promise { + const user = await this.userRepository.findById(this.user.id); + if (!user) { + throw new HttpErrors.NotFound('User not found'); + } + const instructionKey = + process.env.INSTRUCTION_KEY_PREMIUM_PERMISSIONS ?? ''; + const keyMatch = await this.hasher.comparePassword( + request.key, + instructionKey, + ); + if (!keyMatch) { + throw new HttpErrors.Unauthorized('Invalid password'); + } + const instruction = + await this.instructionRepository.findById(instructionId); + instruction.premiumUserIds = instruction.premiumUserIds ?? []; + if (instruction.premiumUserIds.includes(userId)) { + instruction.premiumUserIds = instruction.premiumUserIds.filter( + id => id !== userId, + ); + } else { + instruction.premiumUserIds.push(userId); + } + await this.instructionRepository.updateById(instructionId, instruction); + return true; + } + @get('/public-instructions', { responses: { '200': { @@ -621,59 +705,6 @@ export class UserInstructionController { return data; } - @patch('/authorizate-for-premium-instruction/{instructionId}/{userId}', { - responses: { - '200': { - description: 'Authorize user for premium instructions', - content: { - 'application/json': { - schema: { - type: 'boolean', - }, - }, - }, - }, - }, - }) - async authorizeUserToPremiumInstruction( - @requestBody({ - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - key: {type: 'string'}, - }, - required: ['key'], - }, - }, - }, - }) - request: { - key: string; - }, - @param.query.number('instructionId') instructionId: number, - @param.query.number('userId') userId: number, - ): Promise { - const instructionKey = process.env.INSTRUCTION_KEY_PERMISSIONS ?? ''; - const keyMatch = await this.hasher.comparePassword( - request.key, - instructionKey, - ); - if (!keyMatch) { - throw new HttpErrors.Unauthorized('Invalid password'); - } - const instruction = await this.instructionRepository.findById(instructionId); - instruction.premiumUserIds = instruction.premiumUserIds ?? []; - if (instruction.premiumUserIds.includes(userId)) { - instruction.premiumUserIds = instruction.premiumUserIds.filter(id => id !== userId); - } else { - instruction.premiumUserIds.push(userId); - } - await this.instructionRepository.updateById(instructionId, instruction); - return true; - } - private validateInstructionOwnership(instruction: Instruction): void { if (Number(instruction.userId) !== Number(this.user.id)) { throw new HttpErrors.Forbidden( diff --git a/src/controllers/user-link.controller.ts b/src/controllers/user-link.controller.ts index 8f69333..2c3bab9 100644 --- a/src/controllers/user-link.controller.ts +++ b/src/controllers/user-link.controller.ts @@ -16,10 +16,23 @@ export class UserLinkController { @repository(UserRepository) public userRepository: UserRepository, @repository(UserLinkRepository) public userLinkRepository: UserLinkRepository, - ) {} + ) { } @authenticate('jwt') - @post('/users/{id}/follow/{followeeId}') + @post('/users/{id}/follow/{followeeId}', { + responses: { + '200': { + description: 'Follow User', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) async followUser( @param.path.number('followeeId') followeeId: number, ): Promise { @@ -39,7 +52,20 @@ export class UserLinkController { } @authenticate('jwt') - @del('/users/{id}/unfollow/{followeeId}') + @del('/users/{id}/unfollow/{followeeId}', { + responses: { + '200': { + description: 'Unfollow User', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) async unfollowUser( @param.path.number('followeeId') followeeId: number, ): Promise { @@ -63,12 +89,30 @@ export class UserLinkController { return true; } - @get('/users/{id}/followers') + @get('/users/{userId}/followers', { + responses: { + '200': { + description: 'Get followers', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: {type: 'number'}, + username: {type: 'string'}, + link: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }) async getFollowers( - @param.path.number('id') userId: number, + @param.path.number('userId') userId: number, @param.query.number('limit') limit: number = 10, @param.query.number('offset') offset: number = 0, - ): Promise { + ): Promise> { const userLinks = await this.userLinkRepository.find({ where: { followeeId: userId, @@ -81,16 +125,49 @@ export class UserLinkController { where: { id: {inq: followerIds}, }, + fields: { + email: false, + passwordHash: false, + wrappedDEK: false, + initializationVector: false, + kekSalt: false, + language: false, + darkmode: false, + emailVerified: false, + date: false, + nick: false, + bio: false, + deleteHash: false, + favorites: false, + }, }); return followers; } - @get('/users/{id}/following') - async getFollowing( - @param.path.number('id') userId: number, + @get('/users/{userId}/followees', { + responses: { + '200': { + description: 'Get followees', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: {type: 'number'}, + username: {type: 'string'}, + link: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }) + async getFollowees( + @param.path.number('userId') userId: number, @param.query.number('limit') limit: number = 10, @param.query.number('offset') offset: number = 0, - ): Promise { + ): Promise> { const userLinks = await this.userLinkRepository.find({ where: { followerId: userId, @@ -103,6 +180,21 @@ export class UserLinkController { where: { id: {inq: followeeIds}, }, + fields: { + email: false, + passwordHash: false, + wrappedDEK: false, + initializationVector: false, + kekSalt: false, + language: false, + darkmode: false, + emailVerified: false, + date: false, + nick: false, + bio: false, + deleteHash: false, + favorites: false, + }, }); return following; } diff --git a/src/controllers/user-progress.controller.ts b/src/controllers/user-progress.controller.ts new file mode 100644 index 0000000..335a6a1 --- /dev/null +++ b/src/controllers/user-progress.controller.ts @@ -0,0 +1,226 @@ +import {authenticate} from '@loopback/authentication'; +import { + JWTService +} from '@loopback/authentication-jwt'; +import {inject} from '@loopback/context'; +import {repository} from '@loopback/repository'; +import { + HttpErrors, + del, + get, + param, + patch, + post, + requestBody +} from '@loopback/rest'; +import {SecurityBindings, UserProfile} from '@loopback/security'; +import {Progress, ProgressRelations} from '../models'; +import { + InstructionRepository, + ProgressRepository, + UserRepository +} from '../repositories'; + +export class UserProgressController { + constructor( + @inject('services.jwt.service') + public jwtService: JWTService, + @inject(SecurityBindings.USER, {optional: true}) + public user: UserProfile, + @repository(InstructionRepository) + protected instructionRepository: InstructionRepository, + @repository(UserRepository) protected userRepository: UserRepository, + @repository(UserRepository) + protected progressRepository: ProgressRepository, + ) { } + + @authenticate('jwt') + @post('/users/{id}/progresses/{progressId}', { + responses: { + '200': { + description: 'Create Progress of Instruction', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) + async createProgress( + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + instructionId: {type: 'number'}, + stepId: {type: 'number'}, + descriptionId: {type: 'number'}, + time: {type: 'number'}, + }, + required: ['instructionId', 'stepId', 'descriptionId', 'time'], + }, + }, + }, + }) + progress: Omit, + ): Promise { + const user = await this.userRepository.findById(this.user.id); + if (!user) { + throw new HttpErrors.NotFound('User not found'); + } + const existingProgress = await this.progressRepository.findOne({ + where: { + instructionId: progress.instructionId, + userId: this.user.id, + }, + }); + if (existingProgress) { + throw new HttpErrors.BadRequest( + 'Progress with this instructionId and userId already exists.', + ); + } + await this.progressRepository.create({ + ...progress, + userId: this.user.id, + }); + return true; + } + + @authenticate('jwt') + @patch('/users/{id}/progresses/{instructionId}', { + responses: { + '200': { + description: 'Update Progressof Instruction', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) + async patchProgress( + @param.path.number('instructionId') instructionId: number, + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + stepId: {type: 'number'}, + descriptionId: {type: 'number'}, + time: {type: 'number'}, + }, + }, + }, + }, + }) + progress: Partial, + ): Promise { + const userOriginal = await this.userRepository.findById(this.user.id); + if (!userOriginal) { + throw new HttpErrors.NotFound('User not found'); + } + const progressOriginal = await this.progressRepository.findOne({ + where: { + instructionId: instructionId, + userId: this.user.id, + }, + }); + if (!progressOriginal) { + throw new HttpErrors.NotFound('Progress not found'); + } + this.validateProgressOwnership(progressOriginal); + await this.instructionRepository.updateById(progressOriginal.id, progress); + return true; + } + + @authenticate('jwt') + @del('/users/{id}/progresses/{instructionId}', { + responses: { + '200': { + description: 'User.Progress DELETE success count', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) + async deleteProgress( + @param.path.number('instructionId') instructionId: number, + ): Promise { + const userOriginal = await this.userRepository.findById(this.user.id); + if (!userOriginal) { + throw new HttpErrors.NotFound('User not found'); + } + const progress = await this.progressRepository.findOne({ + where: { + instructionId: instructionId, + userId: this.user.id, + }, + }); + if (!progress) { + throw new HttpErrors.NotFound('Progress not found'); + } + this.validateProgressOwnership(progress); + await this.progressRepository.deleteById(progress.id); + return true; + } + + @authenticate('jwt') + @get('/users/{id}/progresses/{instructionId}', { + responses: { + '200': { + description: 'Progress model instance', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: {type: 'number'}, + instructionId: {type: 'number'}, + stepId: {type: 'number'}, + descriptionId: {type: 'number'}, + userId: {type: 'number'}, + }, + }, + }, + }, + }, + }, + }) + async getProgress( + @param.path.number('instructionId') instructionId: number, + ): Promise { + const user = await this.userRepository.findById(this.user.id); + if (!user) { + throw new HttpErrors.NotFound('User not found'); + } + const progress = await this.progressRepository.findOne({ + where: { + instructionId: instructionId, + userId: this.user.id, + }, + }); + if (!progress) { + throw new HttpErrors.NotFound('Progress not found'); + } + return progress; + } + + private validateProgressOwnership(progress: Progress): void { + if (Number(progress.userId) !== Number(this.user.id)) { + throw new HttpErrors.Forbidden('You are not authorized to this progress'); + } + } +} diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index f1f6891..a4c6d4c 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -2,6 +2,7 @@ import {authenticate} from '@loopback/authentication'; import { Credentials, JWTService, + TokenObject, UserCredentials, } from '@loopback/authentication-jwt'; import {inject} from '@loopback/context'; @@ -62,7 +63,7 @@ export class UserController { @repository(StepRepository) public stepRepository: StepRepository, @repository(UserLinkRepository) public userLinkRepository: UserLinkRepository, - ) {} + ) { } @post('/login', { responses: { @@ -99,7 +100,7 @@ export class UserController { }, }) credentials: Credentials, - ): Promise<{token: string; tokenKMS: string}> { + ): Promise { const user = await this.userService.verifyCredentials(credentials); const userProfile = this.userService.convertToUserProfile(user); const existingUser = await this.userRepository.findOne({ @@ -109,11 +110,65 @@ export class UserController { throw new HttpErrors.UnprocessableEntity('email is not verified'); } const token = await this.jwtService.generateToken(userProfile); - const tokenKMS = await this.vaultService.authenticate( - String(existingUser.id), - credentials.password, - ); - return {token, tokenKMS}; + return token; + } + + @post('/refresh-token', { + responses: { + '200': { + description: 'Refresh token', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }) + async refreshToken( + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: {type: 'string'}, + }, + required: [ + 'token', + ], + }, + }, + }, + }) + requestBody: {refreshToken: string}, + ): Promise { + try { + const {refreshToken} = requestBody; + if (!refreshToken) { + throw new HttpErrors.Unauthorized( + `Error verifying token: 'refresh token' is null`, + ); + } + const userRefreshData = await this.jwtService.verifyToken(refreshToken); + const user = await this.userRepository.findById( + userRefreshData.userId.toString(), + ); + const userProfile: UserProfile = this.userService.convertToUserProfile(user); + const token = await this.jwtService.generateToken(userProfile); + return { + accessToken: token, + }; + } catch (error) { + throw new HttpErrors.Unauthorized( + `Error verifying token: ${error.message}`, + ); + } } @post('/signup', { @@ -123,7 +178,10 @@ export class UserController { content: { 'application/json': { schema: { - type: 'boolean', + type: 'object', + properties: { + token: {type: 'string'}, + }, }, }, }, @@ -142,7 +200,6 @@ export class UserController { password0: {type: 'string'}, password1: {type: 'string'}, language: {enum: Object.values(Language)}, - wrappedDEK: {type: 'string'}, kekSalt: {type: 'string'}, initializationVector: {type: 'string'}, }, @@ -152,7 +209,6 @@ export class UserController { 'password0', 'password1', 'language', - 'wrappedDEK', 'kekSalt', 'initializationVector', ], @@ -178,26 +234,100 @@ export class UserController { const hashedPassword = await this.hasher.hashPassword( credentials.password0, ); - await this.vaultService.createUser( - String(credentials.id), - credentials.password0, - ); - const tokenKMS = await this.vaultService.authenticate( - String(credentials.id), - credentials.password0, - ); const newUser = new User({ email: credentials.email, username: credentials.username, passwordHash: hashedPassword, - wrappedDEK: credentials.wrappedDEK.toString('base64'), + wrappedDEK: 'null', kekSalt: credentials.kekSalt, initializationVector: credentials.initializationVector.toString('base64'), language: credentials.language, }); const dbUser = await this.userRepository.create(newUser); + await this.vaultService.createUserPolicy(String(dbUser.id)); + await this.vaultService.createUser( + String(dbUser.id), + credentials.password0, + ); + await this.vaultService.createUserKey(String(dbUser.id)); await this.emailService.sendRegistrationEmail(dbUser); - return tokenKMS; + const secret = process.env.JWT_SECRET_SIGNUP ?? ''; + const userId = dbUser.id; + const token = jwt.sign({userId}, secret, { + expiresIn: 60, + algorithm: 'HS256', + }); + return token; + } + + @post('/save-Wrapped-DEK', { + responses: { + '200': { + description: 'Save Wrapped DEK', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }) + async saveWrappedDEK( + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: {type: 'string'}, + wrappedDEK: {type: 'string'}, + }, + required: ['token', 'wrappedDEK'], + }, + }, + }, + }) + request: { + token: string; + wrappedDEK: string; + }, + ): Promise { + interface DecodedToken { + userId: number; + iat: number; + exp: number; + } + try { + const {token} = request; + const secret = process.env.JWT_SECRET_SIGNUP ?? ''; + const decodedToken = jwt.verify(token, secret) as DecodedToken; + const {userId} = decodedToken; + const user = await this.userRepository.findById(userId); + if (!user) { + throw new HttpErrors.UnprocessableEntity('User not found'); + } + if (user.wrappedDEK !== 'null') { + throw new HttpErrors.UnprocessableEntity('DEK already saved'); + } + await this.userRepository.updateById(user.id, { + wrappedDEK: request.wrappedDEK, + }); + return true; + } catch (error) { + if (error.name === 'TokenExpiredError') { + throw new HttpErrors.UnprocessableEntity( + 'Verification token has expired', + ); + } else if (error.name === 'JsonWebTokenError') { + throw new HttpErrors.UnprocessableEntity('Invalid verification token'); + } else { + throw new HttpErrors.UnprocessableEntity( + 'Failed to update user email verification status', + ); + } + } } @post('/verify-email', { @@ -239,7 +369,7 @@ export class UserController { } try { const {token} = request; - const secret = process.env.JWT_SECRET ?? ''; + const secret = process.env.JWT_SECRET_EMAIL ?? ''; const decodedToken = jwt.verify(token, secret) as DecodedToken; const {userId} = decodedToken; const user = await this.userRepository.findById(userId); @@ -349,7 +479,7 @@ export class UserController { exp: number; } try { - const secret = process.env.JWT_SECRET ?? ''; + const secret = process.env.JWT_SECRET_EMAIL ?? ''; const decodedToken = jwt.verify(request.token, secret) as DecodedToken; const {userData} = decodedToken; const user = await this.userRepository.findById(userData); @@ -394,14 +524,15 @@ export class UserController { id: {type: 'number'}, email: {type: 'string'}, username: {type: 'string'}, + wrappedDEK: {type: 'string'}, + initializationVector: {type: 'string'}, + kekSalt: {type: 'string'}, language: {enum: Object.values(Language)}, - darkmode: {type: 'string'}, - emailVerified: {type: 'string'}, + darkmode: {type: 'boolean'}, date: {type: 'string'}, nick: {type: 'string'}, bio: {type: 'string'}, link: {type: 'string'}, - wrappedDEK: {type: 'string'}, favorites: { type: 'array', items: { @@ -418,14 +549,12 @@ export class UserController { async getUser(): Promise< Omit< User, - 'passwordHash' | 'initializationVector' | 'kekSalt' | 'deleteHash' + 'passwordHash' | 'deleteHash' > > { const user = await this.userRepository.findById(this.user.id, { fields: { passwordHash: false, - initializationVector: false, - kekSalt: false, deleteHash: false, }, }); @@ -494,7 +623,7 @@ export class UserController { } @authenticate('jwt') - @del('/users/{id}', { + @del('/users/{id}/{password}', { responses: { '200': { description: 'Delete user', @@ -509,35 +638,22 @@ export class UserController { }, }) async deleteUser( - @requestBody({ - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - password: {type: 'string'}, - }, - required: ['password'], - }, - }, - }, - }) - request: { - password: string; - }, + @param.path.password('password') password: string, ): Promise { const userOriginal = await this.userRepository.findById(this.user.id); if (!userOriginal) { throw new HttpErrors.NotFound('User not found'); } const passwordMatched = await this.hasher.comparePassword( - request.password, + password, userOriginal.passwordHash, ); if (!passwordMatched) { throw new HttpErrors.Unauthorized('Password is not valid'); } + await this.vaultService.deleteUserKey(String(userOriginal.id)); await this.vaultService.deleteUser(String(userOriginal.id)); + await this.vaultService.deleteUserPolicy(String(userOriginal.id)); if (userOriginal.deleteHash) { await this.imgurService.deleteImage(userOriginal.deleteHash); } @@ -722,7 +838,7 @@ export class UserController { return usernamesAndLinks; } - @get('/user-detail/{id}', { + @get('/user-detail/{userId}', { responses: { '200': { description: 'Get user detail', @@ -779,7 +895,9 @@ export class UserController { }, }, }) - async getUserDetail(@param.query.number('id') userId: number): Promise<{ + async getUserDetail( + @param.path.number('userId') userId: number + ): Promise<{ user: Omit< User, | 'email' @@ -796,8 +914,8 @@ export class UserController { >; followerCount: number; followeeCount: number; - instructions: Omit[]; - instructionsPremium: Omit[]; + instructions: Omit[] | null; + instructionsPremium: Omit[] | null; }> { const user = await this.userRepository.findById(userId, { fields: { diff --git a/src/datasources/db.datasource.ts b/src/datasources/db.datasource.ts index 0240f77..7f5864d 100644 --- a/src/datasources/db.datasource.ts +++ b/src/datasources/db.datasource.ts @@ -6,11 +6,11 @@ dotenv.config(); const config = { name: 'db', connector: 'postgresql', - host: process.env.SQLHOST, - port: Number(process.env.SQLPORT), - user: process.env.SQLUSER, - password: process.env.SQLPASSWORD, - database: process.env.SQLDATABASE, + host: process.env.SQL_HOST, + port: Number(process.env.SQL_PORT), + user: process.env.SQL_USER, + password: process.env.SQL_PASSWORD, + database: process.env.SQL_DATABASE, }; // Observe application's life cycle to disconnect the datasource when diff --git a/src/datasources/email.datasource.ts b/src/datasources/email.datasource.ts index f92a951..227e42f 100644 --- a/src/datasources/email.datasource.ts +++ b/src/datasources/email.datasource.ts @@ -3,12 +3,12 @@ import * as nodemailer from 'nodemailer'; dotenv.config(); const config = { - host: process.env.EMAILHOST, + host: process.env.EMAIL_HOST, secure: true, - port: Number(process.env.EMAILPORT), + port: Number(process.env.EMAIL_PORT), auth: { - user: process.env.EMAILUSER, - pass: process.env.EMAILPASSWORD, + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD, }, }; diff --git a/src/index.ts b/src/index.ts index 4187fb3..82da0ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import * as dotenv from 'dotenv'; import {ApplicationConfig, SelecroBackendApplication} from './application'; -//import {SocketController} from './controllers'; dotenv.config(); export * from './application'; @@ -21,8 +20,8 @@ if (require.main === module) { // Run the application const config = { rest: { - port: +(process.env.EXTPORT ?? 3000), - host: process.env.HOST, + port: +(process.env.DEFAULT_PORT ?? 3000), + host: process.env.DEFAULT_HOST, // The `gracePeriodForClose` provides a graceful close for http/https // servers with keep-alive clients. The default value is `Infinity` // (don't force-close). If you want to immediately destroy all sockets diff --git a/src/models/index.ts b/src/models/index.ts index 51a2391..b3dd252 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,3 +2,4 @@ export * from './instruction.model'; export * from './step.model'; export * from './user-link.model'; export * from './user.model'; +export * from './progress.model'; diff --git a/src/models/instruction.model.ts b/src/models/instruction.model.ts index d539248..891618e 100644 --- a/src/models/instruction.model.ts +++ b/src/models/instruction.model.ts @@ -153,7 +153,7 @@ export class Instruction extends Entity { userId: number; @hasMany(() => Step, {keyTo: 'instructionId'}) - steps: Step[]; + steps?: Step[]; constructor(data?: Partial) { super(data); diff --git a/src/models/progress.model.ts b/src/models/progress.model.ts new file mode 100644 index 0000000..ad11bca --- /dev/null +++ b/src/models/progress.model.ts @@ -0,0 +1,90 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model({ + name: 'progress', +}) +export class Progress extends Entity { + @property({ + id: true, + generated: true, + postgresql: { + columnName: 'id', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 'NO', + }, + }) + id: number; + + @property({ + type: 'number', + required: true, + postgresql: { + columnName: 'instruction_id', + dataType: 'integer', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + instructionId: number; + + @property({ + type: 'number', + required: true, + postgresql: { + columnName: 'step_id', + dataType: 'integer', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + stepId: number; + + @property({ + type: 'number', + required: true, + postgresql: { + columnName: 'description_id', + dataType: 'integer', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + descriptionId: number; + + @property({ + type: 'number', + required: true, + postgresql: { + columnName: 'time', + dataType: 'integer', + dataLength: null, + dataPrecision: null, + dataScale: null, + nullable: 'NO', + }, + }) + time: number; + + @property({ + type: 'number', + }) + userId: number; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ProgressRelations { + // describe navigational properties here +} + +export type ProgressWithRelations = Progress & ProgressRelations; diff --git a/src/models/user.model.ts b/src/models/user.model.ts index a3e584d..4b3d48c 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -1,6 +1,7 @@ import {Entity, hasMany, model, property} from '@loopback/repository'; import {UserLink} from '.'; import {Instruction} from './instruction.model'; +import {Progress} from './progress.model'; export enum Language { CZ = 'CZ', @@ -240,10 +241,13 @@ export class User extends Entity { keyTo: 'followeeId', }, }) - users: User[]; + users?: User[]; @hasMany(() => Instruction, {keyTo: 'userId'}) - instructions: Instruction[]; + instructions?: Instruction[]; + + @hasMany(() => Progress, {keyTo: 'userId'}) + progresses?: Progress[]; constructor(data?: Partial) { super(data); diff --git a/src/repositories/index.ts b/src/repositories/index.ts index 68d6cce..9ab65fb 100644 --- a/src/repositories/index.ts +++ b/src/repositories/index.ts @@ -2,3 +2,4 @@ export * from './instruction.repository'; export * from './step.repository'; export * from './user-link.repository'; export * from './user.repository'; +export * from './progress.repository'; diff --git a/src/repositories/progress.repository.ts b/src/repositories/progress.repository.ts new file mode 100644 index 0000000..25d0341 --- /dev/null +++ b/src/repositories/progress.repository.ts @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {DbDataSource} from '../datasources'; +import {Progress, ProgressRelations} from '../models'; + +export class ProgressRepository extends DefaultCrudRepository< + Progress, + typeof Progress.prototype.id, + ProgressRelations +> { + constructor(@inject('datasources.db') dataSource: DbDataSource) { + super(Progress, dataSource); + } +} diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index af68201..6fca5c3 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -1,12 +1,13 @@ -import {inject, Getter} from '@loopback/core'; +import {Getter, inject} from '@loopback/core'; import { DefaultCrudRepository, - repository, HasManyRepositoryFactory, + repository, } from '@loopback/repository'; import {DbDataSource} from '../datasources'; -import {User, UserRelations, Instruction} from '../models'; +import {Instruction, Progress, User, UserRelations} from '../models'; import {InstructionRepository} from './instruction.repository'; +import {ProgressRepository} from './progress.repository'; export class UserRepository extends DefaultCrudRepository< User, @@ -18,12 +19,27 @@ export class UserRepository extends DefaultCrudRepository< typeof User.prototype.id >; + public readonly progresses: HasManyRepositoryFactory< + Progress, + typeof User.prototype.id + >; + constructor( @inject('datasources.db') dataSource: DbDataSource, @repository.getter('InstructionRepository') protected instructionRepositoryGetter: Getter, + @repository.getter('ProgressRepository') + protected progressRepositoryGetter: Getter, ) { super(User, dataSource); + this.progresses = this.createHasManyRepositoryFactoryFor( + 'progresses', + progressRepositoryGetter, + ); + this.registerInclusionResolver( + 'progresses', + this.progresses.inclusionResolver, + ); this.instructions = this.createHasManyRepositoryFactoryFor( 'instructions', instructionRepositoryGetter, diff --git a/src/services/email.ts b/src/services/email.ts index fb0bc37..94e4482 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -11,7 +11,7 @@ export class EmailService { constructor() {} public generateVerificationToken(userId: number): string { - const secret = process.env.JWT_SECRET ?? ''; + const secret = process.env.JWT_SECRET_EMAIL ?? ''; const token = jwt.sign({userId}, secret, { expiresIn: '1h', algorithm: 'HS256', @@ -28,7 +28,7 @@ export class EmailService { ); body = body.replace('{{URL}}', url); await EmailDataSource.sendMail({ - from: process.env.EMAILUSER, + from: process.env.EMAIL_USER, to: user.email, subject: 'Selecro: Registration', html: body, @@ -48,13 +48,13 @@ export class EmailService { ); body0 = body0.replace('{{URL}}', url); await EmailDataSource.sendMail({ - from: process.env.EMAILUSER, + from: process.env.EMAIL_USER, to: email, subject: 'Selecro: Email verification', html: body0, }); await EmailDataSource.sendMail({ - from: process.env.EMAILUSER, + from: process.env.EMAIL_USER, to: user.email, subject: 'Selecro: Email change', html: body1, @@ -70,7 +70,7 @@ export class EmailService { ); body = body.replace('{{URL}}', url); await EmailDataSource.sendMail({ - from: process.env.EMAILUSER, + from: process.env.EMAIL_USER, to: user.email, subject: 'Selecro: Change password', html: body, @@ -83,7 +83,7 @@ export class EmailService { 'utf-8', ); await EmailDataSource.sendMail({ - from: process.env.EMAILUSER, + from: process.env.EMAIL_USER, to: user.email, subject: 'Selecro: Successfuly changed password', html: body, diff --git a/src/services/example-user-policy.hcl b/src/services/example-user-policy.hcl new file mode 100644 index 0000000..9de5197 --- /dev/null +++ b/src/services/example-user-policy.hcl @@ -0,0 +1,12 @@ +path "transit/encrypt/{{id}}/*" { + capabilities = ["create", "read"] +} +path "transit/decrypt/{{id}}/*" { + capabilities = ["create", "read"] +} +path "auth/token/renew-self" { + capabilities = ["update"] +} +path "auth/userpass/login/*" { + capabilities = ["create"] +} diff --git a/src/services/imgur-service.ts b/src/services/imgur-service.ts index 9a8ca9c..1210266 100644 --- a/src/services/imgur-service.ts +++ b/src/services/imgur-service.ts @@ -5,7 +5,7 @@ import multer from 'multer'; dotenv.config(); export class ImgurService { - private readonly clientId = process.env.CLIENT_ID ?? ''; + private readonly clientId = process.env.IMGUR_CLIENT_ID ?? ''; constructor() {} diff --git a/src/services/jwt-service.ts b/src/services/jwt-service.ts index 5ab8bc0..b7f53e0 100644 --- a/src/services/jwt-service.ts +++ b/src/services/jwt-service.ts @@ -4,10 +4,17 @@ // License text available at https://opensource.org/licenses/MIT import {TokenService} from '@loopback/authentication'; +import {TokenObject} from '@loopback/authentication-jwt'; import {inject} from '@loopback/core'; +import {repository} from '@loopback/repository'; import {HttpErrors} from '@loopback/rest'; -import {securityId, UserProfile} from '@loopback/security'; +import {UserProfile, securityId} from '@loopback/security'; import {promisify} from 'util'; +import { + UserRepository +} from '../repositories'; +import {MyUserService} from './user-service'; + const jwt = require('jsonwebtoken'); const signAsync = promisify(jwt.sign); const verifyAsync = promisify(jwt.verify); @@ -18,7 +25,10 @@ export class JWTService implements TokenService { private jwtSecret: string, @inject('authentication.jwt.expiresIn') private jwtExpiresIn: string, - ) {} + @inject('services.user.service') + public userService: MyUserService, + @repository(UserRepository) protected userRepository: UserRepository, + ) { } async verifyToken(token: string): Promise { if (!token) { @@ -69,4 +79,29 @@ export class JWTService implements TokenService { } return token; } + + async refreshToken(refreshToken: string): Promise { + try { + if (!refreshToken) { + throw new HttpErrors.Unauthorized( + `Error verifying token : 'refresh token' is null`, + ); + } + const userRefreshData = await this.verifyToken(refreshToken); + const user = await this.userRepository.findById( + userRefreshData.userId.toString(), + ); + const userProfile: UserProfile = + this.userService.convertToUserProfile(user); + // create a JSON Web Token based on the user profile + const token = await this.generateToken(userProfile); + return { + accessToken: token, + }; + } catch (error) { + throw new HttpErrors.Unauthorized( + `Error verifying token : ${error.message}`, + ); + } + } } diff --git a/src/services/user-service.ts b/src/services/user-service.ts index dca8952..ef78408 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -14,7 +14,7 @@ export class MyUserService implements UserService { public userRepository: UserRepository, @inject('services.hasher') public hasher: BcryptHasher, - ) {} + ) { } async verifyCredentials(credentials: Credentials): Promise { const foundUser0 = await this.userRepository.findOne({ diff --git a/src/services/vault-service.ts b/src/services/vault-service.ts index 8446e32..0d34e5d 100644 --- a/src/services/vault-service.ts +++ b/src/services/vault-service.ts @@ -1,5 +1,6 @@ import fetch from 'cross-fetch'; import * as dotenv from 'dotenv'; +import * as fs from 'fs'; dotenv.config(); export class VaultService { @@ -8,8 +9,9 @@ export class VaultService { private readonly unsealKeys: string[] = [ process.env.UNSEAL_KEY_1 ?? '', process.env.UNSEAL_KEY_2 ?? '', + process.env.UNSEAL_KEY_3 ?? '', ]; - private readonly rootToken = process.env.ROOT_VAULT ?? ''; + private readonly rootToken = process.env.ROOT_VAULT_TOKEN ?? ''; constructor() { this.checkAndUnsealIfNeeded().catch(error => { @@ -21,9 +23,6 @@ export class VaultService { try { const response = await fetch(`${this.vaultEndpoint}/v1/sys/seal-status`, { method: 'GET', - headers: { - 'X-Vault-Token': this.rootToken, - }, }); if (!response.ok) { throw new Error(`Status check error: ${response.statusText}`); @@ -39,7 +38,11 @@ export class VaultService { private async unseal(): Promise { try { - const unsealKeys: string[] = [this.unsealKeys[0], this.unsealKeys[1]]; + const unsealKeys: string[] = [ + this.unsealKeys[0], + this.unsealKeys[1], + this.unsealKeys[2], + ]; for (const key of unsealKeys) { const response = await fetch(`${this.vaultEndpoint}/v1/sys/unseal`, { method: 'POST', @@ -52,9 +55,7 @@ export class VaultService { }), }); if (!response.ok) { - throw new Error( - `Unseal error with key ${key}: ${response.statusText}`, - ); + throw new Error(`Unseal error`); } } } catch (error) { @@ -62,53 +63,68 @@ export class VaultService { } } - async authenticate(password: string, id: string): Promise { + async createUser(password: string, id: string): Promise { try { const data = { password: password, + policies: [String(id)], }; const response = await fetch( - `${this.vaultEndpoint}/v1/auth/userpass/login/${id}`, + `${this.vaultEndpoint}/v1/auth/userpass/users/${id}`, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'X-Vault-Token': this.rootToken, }, body: JSON.stringify(data), }, ); if (!response.ok) { - throw new Error( - `Authentication error: Unable to authenticate with the provided credentials.`, - ); + throw new Error(`Unable to create user`); } - const responseData = await response.json(); - return responseData.auth.client_token; } catch (error) { throw new Error(`Authentication error: ${error.message}`); } } - async createUser(password: string, id: string): Promise { + async createUserPolicy(id: string): Promise { try { - const data = { - password: password, - policies: ['selecro-main'], - }; + let policyData = fs.readFileSync( + `./src/services/example-user-policy.hcl`, + 'utf-8', + ); + policyData = policyData.replace('{{id}}', id); const response = await fetch( - `${this.vaultEndpoint}/v1/auth/userpass/users/${id}`, + `${this.vaultEndpoint}/v1/sys/policy/${id}`, { method: 'POST', headers: { 'X-Vault-Token': this.rootToken, }, - body: JSON.stringify(data), + body: JSON.stringify({data: policyData}), }, ); if (!response.ok) { - throw new Error( - `Authentication error: Unable to authenticate with the provided credentials.`, - ); + throw new Error(`Unable to create policy`); + } + } catch (error) { + throw new Error(`Authentication error: ${error.message}`); + } + } + + async createUserKey(id: string): Promise { + try { + const response = await fetch( + `${this.vaultEndpoint}/v1/transit/keys/${id}`, + { + method: 'POST', + headers: { + 'X-Vault-Token': this.rootToken, + }, + }, + ); + if (!response.ok) { + throw new Error(`Unable to create key`); } } catch (error) { throw new Error(`Authentication error: ${error.message}`); @@ -131,9 +147,7 @@ export class VaultService { }, ); if (!response.ok) { - throw new Error( - `Authentication error: Unable to authenticate with the provided credentials.`, - ); + throw new Error(`Unable to update password`); } } catch (error) { throw new Error(`Authentication error: ${error.message}`); @@ -152,9 +166,45 @@ export class VaultService { }, ); if (!response.ok) { - throw new Error( - `Authentication error: Unable to authenticate with the provided credentials.`, - ); + throw new Error(`Unable to delete user`); + } + } catch (error) { + throw new Error(`Authentication error: ${error.message}`); + } + } + + async deleteUserPolicy(id: string): Promise { + try { + const response = await fetch( + `${this.vaultEndpoint}/v1/sys/policy/acl/${id}`, + { + method: 'DELETE', + headers: { + 'X-Vault-Token': this.rootToken, + }, + }, + ); + if (!response.ok) { + throw new Error(`Unable to delete policy`); + } + } catch (error) { + throw new Error(`Authentication error: ${error.message}`); + } + } + + async deleteUserKey(id: string): Promise { + try { + const response = await fetch( + `${this.vaultEndpoint}/v1/transit/keys/${id}`, + { + method: 'DELETE', + headers: { + 'X-Vault-Token': this.rootToken, + }, + }, + ); + if (!response.ok) { + throw new Error(`Unable to delete key`); } } catch (error) { throw new Error(`Authentication error: ${error.message}`);