Skip to content

Commit

Permalink
feat: add api endpoint to change ones password
Browse files Browse the repository at this point in the history
  • Loading branch information
paulschwoerer committed Oct 19, 2021
1 parent d82815a commit 273136e
Show file tree
Hide file tree
Showing 16 changed files with 518 additions and 99 deletions.
5 changes: 5 additions & 0 deletions common/dtos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export type RegisterRequestDto = {
password: string;
};

export type ChangePasswordRequestDto = {
currentPassword: string;
newPassword: string;
};

export type UserResponseDto = {
user: User;
artworkToken: string;
Expand Down
54 changes: 34 additions & 20 deletions lib/controllers/AuthController.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
import {
AuthRequestDto,
ChangePasswordRequestDto,
RegisterRequestDto,
User,
UserResponseDto,
} from '@common';
import { AuthService } from '@services/AuthService';
import { FastifyPluginAsync } from 'fastify';
import { UAParser } from 'ua-parser-js';
import {
sendBadRequestError,
sendNotAuthorizedError,
} from '../helpers/responses';
import ChangePasswordSchema from '../schemas/changePassword.json';
import LoginSchema from '../schemas/login.json';
import RegisterSchema from '../schemas/register.json';
import { InvitationsService } from '../services/InvitationsService';
import { Middleware } from './../middlewares/Middleware';

type Credentials = {
username: string;
password: string;
};

type AuthResult = {
user: User;
sessionToken: string;
};

interface AuthService {
authenticate(
credentials: Credentials,
browserInfo: Browser,
): Promise<AuthResult | Error>;
makeJwtToken(): string;
logout(sessionId: string): Promise<Error | undefined>;
}

type Config = {
minimumPasswordLength: number;
sessionMaxAge: number;
Expand Down Expand Up @@ -103,6 +90,33 @@ export function AuthController({
},
);

router.post<{ Body: ChangePasswordRequestDto }>(
'/password',
{ schema: ChangePasswordSchema, preValidation: authMiddleware },
async (request, reply) => {
const { currentPassword, newPassword } = request.body;

if (!request.auth.isValidPassword(currentPassword)) {
return sendNotAuthorizedError(
reply,
'incorrect current password given',
);
}

const result = await authService.changeUserPassword({
activeSessionId: request.auth.getSessionId(),
newPassword,
userId: request.auth.getUserId(),
});

if (result instanceof Error) {
return sendBadRequestError(reply, result.message);
}

return reply.status(204).send();
},
);

router.post<{ Body: RegisterRequestDto }>(
'/register',
{ schema: RegisterSchema },
Expand Down
18 changes: 3 additions & 15 deletions lib/controllers/SessionsController.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
import { RevokeSessionRequestDto, UserSessionsResponseDto } from '@common';
import { SessionsService } from '@services/SessionsService';
import { FastifyPluginAsync } from 'fastify';
import {
RevokeSessionRequestDto,
UserSession,
UserSessionsResponseDto,
} from '@common';
import RevokeSessionSchema from '../schemas/revokeSession.json';
import {
sendNotAuthorizedError,
sendNotFoundError,
} from '../helpers/responses';

interface SessionsService {
findByIdAndUserId(params: {
id: string;
userId: string;
}): Promise<UserSession | undefined>;
findAllByUserId(userId: string): Promise<UserSession[]>;
deleteById(id: string): Promise<Error | undefined>;
}
import RevokeSessionSchema from '../schemas/revokeSession.json';

type Injects = {
sessionsService: SessionsService;
Expand Down
4 changes: 3 additions & 1 deletion lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ const albumsService = createAlbumsService({ db, songsService });
const artworksService = createArtworksService({ config });
const sessionsService = createSessionsService({ db });
const authService = createAuthService({
db,
config,
sessionsService,
config: config.security,
usersService,
});
const artistsService = createArtistsService({
Expand All @@ -48,6 +49,7 @@ const audioFilesService = createAudioFilesService({ db });
const invitationsService = createInvitationsService({
db,
config,
authService,
usersService,
});
const discoverService = createDiscoverService({ db });
Expand Down
8 changes: 1 addition & 7 deletions lib/middlewares/AuthMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuthContext } from '@typings/AuthContext';
import { FastifyRequest } from 'fastify';
import { sendNotAuthorizedError } from '../helpers/responses';
import { Middleware } from './Middleware';
Expand All @@ -17,13 +18,6 @@ type SessionWithUser = {
user: UserWithPassword;
};

interface AuthContext {
getUser(): User;
getUserId(): string;
getSessionId(): string;
isValidPassword(password: string): boolean;
}

declare module 'fastify' {
interface FastifyRequest {
auth: AuthContext;
Expand Down
17 changes: 17 additions & 0 deletions lib/schemas/changePassword.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"body": {
"type": "object",
"properties": {
"currentPassword": {
"type": "string"
},
"newPassword": {
"type": "string"
}
},
"required": [
"currentPassword",
"newPassword"
]
}
}
101 changes: 59 additions & 42 deletions lib/services/AuthService.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,61 @@
import jwt from 'jsonwebtoken';
import { User } from '@common';
import { UserRow } from '../database/rows';
import { comparePasswords } from '../helpers/passwords';
import jwt from 'jsonwebtoken';
import Knex from 'knex';
import { LeafplayerConfig } from '../config';
import { comparePasswords, createPasswordHash } from '../helpers/passwords';
import { getCurrentUnixTimestamp } from '../helpers/time';

type Config = {
sessionMaxAge: number;
secret: string;
};
import { SessionsService } from './SessionsService';
import { UsersService } from './UsersService';

type Credentials = {
username: string;
password: string;
};

type SessionWithUser = {
id: string;
user: UserRow;
};

type Browser = {
name: string;
os: string;
};

type SessionCreateParams = {
userId: string;
browser: Browser;
maxAge: number;
};

interface SessionsService {
create(params: SessionCreateParams): Promise<string>;
findWithUserByToken(token: string): Promise<SessionWithUser | undefined>;
deleteById(id: string): Promise<Error | undefined>;
}

type UserWithPassword = User & {
password: string;
};

interface UsersService {
findByUsername(username: string): Promise<UserWithPassword | undefined>;
}

type Injects = {
usersService: UsersService;
db: Knex;
config: LeafplayerConfig;
sessionsService: SessionsService;
config: Config;
usersService: UsersService;
};

export interface AuthService {
authenticate(
credentials: Credentials,
browser: Browser,
): Promise<{ user: User; sessionToken: string } | Error>;
logout(sessionId: string): Promise<Error | undefined>;
logout(sessionId: string): Promise<void>;
changeUserPassword(params: {
userId: string;
newPassword: string;
activeSessionId: string;
}): Promise<Error | void>;
makeJwtToken(): string;
isValidJwtToken(token: string): boolean;
validatePasswordSecurity(password: string): Error | void;
}

export function createAuthService({
db,
config: { security: securityConfig },
sessionsService,
usersService,
config,
}: Injects): AuthService {
function generateSessionExpireTimestamp(from: number): number {
return from + config.sessionMaxAge;
return from + securityConfig.sessionMaxAge;
}

function validatePasswordSecurity(password: string): Error | void {
if (password.length < securityConfig.minimumPasswordLength) {
return Error(
`Password needs at least ${securityConfig.minimumPasswordLength} characters`,
);
}
}

return {
Expand All @@ -80,7 +69,7 @@ export function createAuthService({
const sessionToken = await sessionsService.create({
userId: user.id,
browser,
maxAge: config.sessionMaxAge,
maxAge: securityConfig.sessionMaxAge,
});

return {
Expand All @@ -93,27 +82,55 @@ export function createAuthService({
};
},

async logout(sessionId) {
logout(sessionId) {
return sessionsService.deleteById(sessionId);
},

async changeUserPassword({ userId, newPassword, activeSessionId }) {
const pwResult = validatePasswordSecurity(newPassword);
if (pwResult instanceof Error) {
return pwResult;
}

await db.transaction(async trx => {
await trx('users')
.update({
password: createPasswordHash(newPassword),
})
.where({
id: userId,
});

await trx('sessions')
.delete()
.where({
userId,
})
.whereNot({
id: activeSessionId,
});
});
},

makeJwtToken() {
return jwt.sign(
{
exp: generateSessionExpireTimestamp(getCurrentUnixTimestamp()),
},
config.secret,
securityConfig.secret,
);
},

isValidJwtToken(token) {
try {
jwt.verify(token, config.secret);
jwt.verify(token, securityConfig.secret);

return true;
} catch (e) {
return false;
}
},

validatePasswordSecurity,
};
}
12 changes: 8 additions & 4 deletions lib/services/InvitationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Knex from 'knex';
import { LeafplayerConfig } from 'lib/config';
import { InvitationRow } from '../database/rows';
import { getCurrentUnixTimestamp } from '../helpers/time';
import { AuthService } from './AuthService';
import { UsersService } from './UsersService';

export enum CreateInvitationResult {
Expand All @@ -12,6 +13,7 @@ export enum CreateInvitationResult {
type Injects = {
db: Knex;
config: LeafplayerConfig;
authService: AuthService;
usersService: UsersService;
};

Expand All @@ -32,6 +34,7 @@ export interface InvitationsService {
export function createInvitationsService({
db,
config: { security: securityConfig },
authService,
usersService,
}: Injects): InvitationsService {
async function isValidInviteCode(code: string): Promise<boolean> {
Expand Down Expand Up @@ -62,10 +65,11 @@ export function createInvitationsService({
return Error('Username taken');
}

if (userDetails.password.length < securityConfig.minimumPasswordLength) {
return Error(
`Password needs at least ${securityConfig.minimumPasswordLength} characters`,
);
const pwResult = authService.validatePasswordSecurity(
userDetails.password,
);
if (pwResult instanceof Error) {
return pwResult;
}

// TODO: a transaction would be nice
Expand Down
Loading

0 comments on commit 273136e

Please sign in to comment.