diff --git a/src/authchecker.ts b/src/authchecker.ts index 36cb40b..54ef4d7 100644 --- a/src/authchecker.ts +++ b/src/authchecker.ts @@ -3,7 +3,7 @@ import * as jwt from "jsonwebtoken"; import { Action } from "routing-controllers"; import { getConnectionManager } from 'typeorm'; import { config } from './config'; -import { IllegalJWTError, NoPermissionError, UserNonexistantOrRefreshtokenInvalidError } from './errors/AuthError'; +import { IllegalJWTError, NoPermissionError, UserDisabledError, UserNonexistantOrRefreshtokenInvalidError } from './errors/AuthError'; import { JwtCreator, JwtUser } from './jwtcreator'; import { User } from './models/entities/User'; @@ -31,6 +31,7 @@ const authchecker = async (action: Action, permissions: string[] | string) => { const user = await getConnectionManager().get().getRepository(User).findOne({ id: jwtPayload["id"], refreshTokenCount: jwtPayload["refreshTokenCount"] }, { relations: ['permissions'] }) if (!user) { throw new UserNonexistantOrRefreshtokenInvalidError() } + if (user.enabled == false) { throw new UserDisabledError(); } if (!jwtPayload["permissions"]) { throw new NoPermissionError(); } action.response.local = {} @@ -63,6 +64,7 @@ const refresh = async (action: Action) => { const user = await getConnectionManager().get().getRepository(User).findOne({ id: jwtPayload["id"], refreshTokenCount: jwtPayload["refreshTokenCount"] }, { relations: ['permissions', 'groups', 'groups.permissions'] }) if (!user) { throw new UserNonexistantOrRefreshtokenInvalidError() } + if (user.enabled == false) { throw new UserDisabledError(); } let newAccess = JwtCreator.createAccess(user); action.response.header("authorization", "Bearer " + newAccess); diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index d6d8d81..98a9f85 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -1,10 +1,12 @@ -import { Body, CookieParam, JsonController, Post, Res } from 'routing-controllers'; +import { Body, CookieParam, JsonController, Param, Post, Res } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { IllegalJWTError, InvalidCredentialsError, JwtNotProvidedError, PasswordNeededError, RefreshTokenCountInvalidError, UsernameOrEmailNeededError } from '../errors/AuthError'; import { UserNotFoundError } from '../errors/UserErrors'; import { CreateAuth } from '../models/actions/CreateAuth'; +import { CreateResetToken } from '../models/actions/CreateResetToken'; import { HandleLogout } from '../models/actions/HandleLogout'; import { RefreshAuth } from '../models/actions/RefreshAuth'; +import { ResetPassword } from '../models/actions/ResetPassword'; import { Auth } from '../models/responses/ResponseAuth'; import { Logout } from '../models/responses/ResponseLogout'; @@ -78,4 +80,24 @@ export class AuthController { } return response.send(auth) } + + @Post("/reset") + @ResponseSchema(Auth) + @ResponseSchema(UserNotFoundError) + @ResponseSchema(UsernameOrEmailNeededError) + @OpenAPI({ description: "Request a password reset token" }) + async getResetToken(@Body({ validate: true }) passwordReset: CreateResetToken) { + //This really shouldn't just get returned, but sent via mail or sth like that. But for dev only this is fine. + return { "resetToken": await passwordReset.toResetToken() }; + } + + @Post("/reset/:token") + @ResponseSchema(Auth) + @ResponseSchema(UserNotFoundError) + @ResponseSchema(UsernameOrEmailNeededError) + @OpenAPI({ description: "Reset a user's password" }) + async resetPassword(@Param("token") token: string, @Body({ validate: true }) passwordReset: ResetPassword) { + passwordReset.resetToken = token; + return await passwordReset.resetPassword(); + } } diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index e4a8e89..560f0c8 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -62,7 +62,7 @@ export class UserController { } user = await this.userRepository.save(user) - return new ResponseUser(await this.userRepository.findOne(user, { relations: ['permissions', 'groups'] })); + return new ResponseUser(await this.userRepository.findOne({ id: user.id }, { relations: ['permissions', 'groups'] })); } @Put('/:id') diff --git a/src/errors/AuthError.ts b/src/errors/AuthError.ts index c2eff13..848ce78 100644 --- a/src/errors/AuthError.ts +++ b/src/errors/AuthError.ts @@ -115,4 +115,26 @@ export class RefreshTokenCountInvalidError extends NotAcceptableError { @IsString() message = "Refresh token count is invalid." +} + +/** + * Error to throw when someone tryes to reset a user's password more than once in 15 minutes. + */ +export class ResetAlreadyRequestedError extends NotAcceptableError { + @IsString() + name = "ResetAlreadyRequestedError" + + @IsString() + message = "You already requested a password reset in the last 15 minutes. \n Please wait until the old reset code expires before requesting a new one." +} + +/** + * Error to throw when someone tries a disabled user's password or login as a disabled user. + */ +export class UserDisabledError extends NotAcceptableError { + @IsString() + name = "UserDisabledError" + + @IsString() + message = "This user is currently disabled. \n Please contact your administrator if this is a mistake." } \ No newline at end of file diff --git a/src/jwtcreator.ts b/src/jwtcreator.ts index e837f13..4290aec 100644 --- a/src/jwtcreator.ts +++ b/src/jwtcreator.ts @@ -33,6 +33,20 @@ export class JwtCreator { exp: expiry_timestamp }, config.jwt_secret) } + + /** + * Creates a new password reset token for a given user. + * The token is valid for 15 minutes or 1 use - whatever comes first. + * @param user User entity that the password reset token shall be created for + */ + public static createReset(user: User) { + let expiry_timestamp = Math.floor(Date.now() / 1000) + 15 * 60; + return jsonwebtoken.sign({ + id: user.id, + refreshTokenCount: user.refreshTokenCount, + exp: expiry_timestamp + }, config.jwt_secret) + } } /** diff --git a/src/models/actions/CreateAuth.ts b/src/models/actions/CreateAuth.ts index dd9c3f3..6b22d7d 100644 --- a/src/models/actions/CreateAuth.ts +++ b/src/models/actions/CreateAuth.ts @@ -1,7 +1,7 @@ import * as argon2 from "argon2"; import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { getConnectionManager } from 'typeorm'; -import { InvalidCredentialsError, PasswordNeededError, UserNotFoundError } from '../../errors/AuthError'; +import { InvalidCredentialsError, PasswordNeededError, UserDisabledError, UserNotFoundError } from '../../errors/AuthError'; import { UsernameOrEmailNeededError } from '../../errors/UserErrors'; import { JwtCreator } from '../../jwtcreator'; import { User } from '../entities/User'; @@ -55,6 +55,7 @@ export class CreateAuth { if (!found_user) { throw new UserNotFoundError(); } + if (found_user.enabled == false) { throw new UserDisabledError(); } if (!(await argon2.verify(found_user.password, this.password + found_user.uuid))) { throw new InvalidCredentialsError(); } diff --git a/src/models/actions/CreateResetToken.ts b/src/models/actions/CreateResetToken.ts new file mode 100644 index 0000000..dcf22f1 --- /dev/null +++ b/src/models/actions/CreateResetToken.ts @@ -0,0 +1,50 @@ +import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { ResetAlreadyRequestedError, UserDisabledError, UserNotFoundError } from '../../errors/AuthError'; +import { UsernameOrEmailNeededError } from '../../errors/UserErrors'; +import { JwtCreator } from '../../jwtcreator'; +import { User } from '../entities/User'; + +/** + * This calss is used to create password reset tokens for users. + * These password reset token can be used to set a new password for the user for the next 15mins. + */ +export class CreateResetToken { + /** + * The username of the user that wants to reset their password. + */ + @IsOptional() + @IsString() + username?: string; + + /** + * The email address of the user that wants to reset their password. + */ + @IsOptional() + @IsEmail() + @IsString() + email?: string; + + + /** + * Create a password reset token based on this. + */ + public async toResetToken(): Promise { + if (this.email === undefined && this.username === undefined) { + throw new UsernameOrEmailNeededError(); + } + let found_user = await getConnectionManager().get().getRepository(User).findOne({ where: [{ username: this.username }, { email: this.email }] }); + if (!found_user) { throw new UserNotFoundError(); } + if (found_user.enabled == false) { throw new UserDisabledError(); } + if (found_user.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 15 * 60)) { throw new ResetAlreadyRequestedError(); } + + found_user.refreshTokenCount = found_user.refreshTokenCount + 1; + found_user.resetRequestedTimestamp = Math.floor(Date.now() / 1000); + await getConnectionManager().get().getRepository(User).save(found_user); + + //Create the reset token + let reset_token = JwtCreator.createReset(found_user); + + return reset_token; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateUser.ts b/src/models/actions/CreateUser.ts index 3839f11..0203d99 100644 --- a/src/models/actions/CreateUser.ts +++ b/src/models/actions/CreateUser.ts @@ -1,5 +1,5 @@ import * as argon2 from "argon2"; -import { IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; import { getConnectionManager } from 'typeorm'; import * as uuid from 'uuid'; import { config } from '../../config'; @@ -63,6 +63,14 @@ export class CreateUser { @IsString() password: string; + /** + * Will the new user be enabled from the start? + * Default: true + */ + @IsBoolean() + @IsOptional() + enabled?: boolean = true; + /** * The new user's groups' id(s). * You can provide either one groupId or an array of groupIDs. @@ -91,6 +99,7 @@ export class CreateUser { newUser.phone = this.phone newUser.password = await argon2.hash(this.password + newUser.uuid); newUser.groups = await this.getGroups(); + newUser.enabled = this.enabled; //TODO: ProfilePics return newUser; diff --git a/src/models/actions/RefreshAuth.ts b/src/models/actions/RefreshAuth.ts index 12470c7..bcc4fbb 100644 --- a/src/models/actions/RefreshAuth.ts +++ b/src/models/actions/RefreshAuth.ts @@ -2,7 +2,7 @@ import { IsOptional, IsString } from 'class-validator'; import * as jsonwebtoken from 'jsonwebtoken'; import { getConnectionManager } from 'typeorm'; import { config } from '../../config'; -import { IllegalJWTError, JwtNotProvidedError, RefreshTokenCountInvalidError, UserNotFoundError } from '../../errors/AuthError'; +import { IllegalJWTError, JwtNotProvidedError, RefreshTokenCountInvalidError, UserDisabledError, UserNotFoundError } from '../../errors/AuthError'; import { JwtCreator } from "../../jwtcreator"; import { User } from '../entities/User'; import { Auth } from '../responses/ResponseAuth'; @@ -39,6 +39,7 @@ export class RefreshAuth { if (!found_user) { throw new UserNotFoundError() } + if (found_user.enabled == false) { throw new UserDisabledError(); } if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError() } diff --git a/src/models/actions/ResetPassword.ts b/src/models/actions/ResetPassword.ts new file mode 100644 index 0000000..2dafc11 --- /dev/null +++ b/src/models/actions/ResetPassword.ts @@ -0,0 +1,57 @@ +import * as argon2 from "argon2"; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import * as jsonwebtoken from 'jsonwebtoken'; +import { getConnectionManager } from 'typeorm'; +import { config } from '../../config'; +import { IllegalJWTError, JwtNotProvidedError, PasswordNeededError, RefreshTokenCountInvalidError, UserNotFoundError } from '../../errors/AuthError'; +import { User } from '../entities/User'; + +/** + * This class can be used to reset a user's password. + * To set a new password the user needs to provide a valid password reset token. + */ +export class ResetPassword { + /** + * The reset token on which the password reset will be based. + */ + @IsOptional() + @IsString() + resetToken?: string; + + /** + * The user's new password + */ + @IsNotEmpty() + @IsString() + password: string; + + + /** + * Create a password reset token based on this. + */ + public async resetPassword(): Promise { + if (!this.resetToken || this.resetToken === undefined) { + throw new JwtNotProvidedError() + } + if (!this.password || this.password === undefined) { + throw new PasswordNeededError() + } + + let decoded; + try { + decoded = jsonwebtoken.verify(this.resetToken, config.jwt_secret) + } catch (error) { + throw new IllegalJWTError() + } + + const found_user = await getConnectionManager().get().getRepository(User).findOne({ id: decoded["id"] }); + if (!found_user) { throw new UserNotFoundError(); } + if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError(); } + + found_user.refreshTokenCount = found_user.refreshTokenCount + 1; + found_user.password = await argon2.hash(this.password + found_user.uuid); + await getConnectionManager().get().getRepository(User).save(found_user); + + return "password reset successfull"; + } +} \ No newline at end of file diff --git a/src/models/entities/User.ts b/src/models/entities/User.ts index 4cafa8d..57bdf5c 100644 --- a/src/models/entities/User.ts +++ b/src/models/entities/User.ts @@ -111,6 +111,15 @@ export class User extends Principal { @IsOptional() profilePic?: string; + /** + * The last time the user requested a password reset. + * Used to prevent spamming of the password reset route. + */ + @Column({ nullable: true, unique: true }) + @IsString() + @IsOptional() + resetRequestedTimestamp?: number; + /** * The actions performed by this user. * For documentation purposes only, will be implemented later.