adds api, guard, tailwind

This commit is contained in:
Joseph Ditton 2021-11-23 14:04:12 -07:00
parent 4ae4e87468
commit 8d0b32f8df
42 changed files with 5440 additions and 3371 deletions

View File

@ -6,4 +6,5 @@ NODE_ENV=development
DATABASE_URL=neststarterappdevelopement DATABASE_URL=neststarterappdevelopement
# recommend using randomkeygen.com to generate a key # recommend using randomkeygen.com to generate a key
ENCRYPTION_KEY=yourencryptionkey ENCRYPTION_KEY=yourencryptionkey
REFRESH_ENCRYPTION_KEY=yourrefreshencryptionkey

6
client/.postcssrc Normal file
View File

@ -0,0 +1,6 @@
{
"plugins": {
"tailwindcss": {},
"autoprefixer": {},
}
}

5
client/app.css Normal file
View File

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,25 +1,40 @@
import { useReducer } from 'react'; import { useState, useEffect } from 'react';
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import { Router } from './components/router'; import { Router } from './components/router';
import { SettingsContext } from './utils/settings_context'; import { ApiContext } from './utils/api_context';
import { AuthContext } from './utils/auth_context';
const settingsReducer = (state, action) => { import { useApi } from './utils/use_api';
switch (action.type) { import { useJwtRefresh } from './utils/use_jwt_refresh';
case 'update': { import './app.css';
return { ...state, ...action.payload };
}
}
return state;
};
export const App = () => { export const App = () => {
const [settings, dispatch] = useReducer(settingsReducer, window.SETTINGS); const [authToken, setAuthToken] = useState(null);
const [loading, setLoading] = useState(true);
// Refresh the jwt token automatically
useJwtRefresh(authToken, setAuthToken);
// api instance
const api = useApi(authToken);
// get initial jwt using refresh token
useEffect(async () => {
const result = await api.get('/refresh_token');
if (result.token) {
setAuthToken(result.token);
}
setLoading(false);
}, []);
if (loading) return null;
return ( return (
<SettingsContext.Provider value={[settings, dispatch]}> <AuthContext.Provider value={[authToken, setAuthToken]}>
<HashRouter> <ApiContext.Provider value={api}>
<Router /> <HashRouter>
</HashRouter> <Router />
</SettingsContext.Provider> </HashRouter>
</ApiContext.Provider>
</AuthContext.Provider>
); );
}; };

View File

@ -0,0 +1,7 @@
export const Button = ({ children, ...other }) => {
return (
<button className="bg-gray-600 pt-2 pb-2 pr-4 pl-4 rounded-lg font-bold text-white" {...other}>
{children}
</button>
);
};

View File

@ -0,0 +1,3 @@
export const Input = (props) => {
return <input className="border-2 rounded-lg p-1" {...props} />;
};

View File

@ -0,0 +1,3 @@
export const Paper = ({ children }) => {
return <div className="shadow-md flex flex-col p-4">{children}</div>;
};

View File

@ -1,20 +1,33 @@
import { useContext } from 'react'; import { useContext, useEffect, useState } from 'react';
import { SettingsContext } from '../../utils/settings_context'; import { ApiContext } from '../../utils/api_context';
import { AuthContext } from '../../utils/auth_context';
export const Home = () => { export const Home = () => {
const [, dispatch] = useContext(SettingsContext); const [, setAuthToken] = useContext(AuthContext);
const api = useContext(ApiContext);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
useEffect(async () => {
const res = await api.get('/users/me');
setUser(res.user);
setLoading(false);
}, []);
const logout = async () => { const logout = async () => {
const res = await fetch('/sessions', { const res = await api.del('/sessions');
method: 'DELETE', if (res.success) {
}); setAuthToken(null);
if (res.status === 200) {
dispatch({ type: 'update', payload: { jwt: undefined } });
} }
}; };
if (loading) {
return <div>Loading...</div>;
}
return ( return (
<div> <div>
<h1>Welcome</h1> <h1>Welcome {user.name}</h1>
<button type="button" onClick={logout}> <button type="button" onClick={logout}>
Logout Logout
</button> </button>

View File

@ -1,18 +1,18 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { Home } from './home/_home'; import { Home } from './home/_home';
import { SettingsContext } from '../utils/settings_context'; import { AuthContext } from '../utils/auth_context';
import { SignIn } from './sign_in/_sign_in'; import { SignIn } from './sign_in/_sign_in';
import { SignUp } from './sign_up/_sign_up'; import { SignUp } from './sign_up/_sign_up';
export const Router = () => { export const Router = () => {
const [settings] = useContext(SettingsContext); const [authToken] = useContext(AuthContext);
const { jwt } = settings;
return ( return (
<Routes> <Routes>
<Route <Route
path="/" path="/"
element={jwt ? <Home /> : <Navigate replace to="signin" />} // no jwt means not logged in element={authToken ? <Home /> : <Navigate replace to="signin" />} // no jwt means not logged in
/> />
<Route path="signin" element={<SignIn />} /> <Route path="signin" element={<SignIn />} />
<Route path="signup" element={<SignUp />} /> <Route path="signup" element={<SignUp />} />

View File

@ -1,9 +1,12 @@
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { SettingsContext } from '../../utils/settings_context'; import { AuthContext } from '../../utils/auth_context';
import { Paper } from '../common/paper';
import { Input } from '../common/input';
import { Button } from '../common/button';
export const SignIn = () => { export const SignIn = () => {
const [, dispatch] = useContext(SettingsContext); const [, setAuthToken] = useContext(AuthContext);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
@ -25,7 +28,7 @@ export const SignIn = () => {
}); });
if (res.status === 201) { if (res.status === 201) {
const result = await res.json(); const result = await res.json();
dispatch({ type: 'update', payload: { jwt: result.token } }); setAuthToken(result.token);
navigate('/'); navigate('/');
} else { } else {
console.error('An issue occurred when logging in.'); console.error('An issue occurred when logging in.');
@ -33,28 +36,23 @@ export const SignIn = () => {
}; };
return ( return (
<div> <div className="flex flex-row justify-center m-4">
<div>Email</div> <div className="w-96">
<input <Paper>
type="email" <div>Email</div>
value={email} <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
onChange={(e) => setEmail(e.target.value)} <div>Password</div>
/> <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<div>Password</div> <div className="flex flex-row justify-end mt-2">
<input <Button type="button" onClick={goToSignUp}>
type="password" Sign up
value={password} </Button>
onChange={(e) => setPassword(e.target.value)} <div className="pl-2" />
/> <Button type="button" onClick={signIn}>
<div> Sign in
<button type="button" onClick={signIn}> </Button>
Sign in </div>
</button> </Paper>
</div>
<div>
<button type="button" onClick={goToSignUp}>
Sign up
</button>
</div> </div>
</div> </div>
); );

View File

@ -1,9 +1,12 @@
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { SettingsContext } from '../../utils/settings_context'; import { AuthContext } from '../../utils/auth_context';
import { Paper } from '../common/paper';
import { Input } from '../common/input';
import { Button } from '../common/button';
export const SignUp = () => { export const SignUp = () => {
const [, dispatch] = useContext(SettingsContext); const [, setAuthToken] = useContext(AuthContext);
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -46,45 +49,36 @@ export const SignUp = () => {
}); });
if (res.status === 201) { if (res.status === 201) {
const result = await res.json(); const result = await res.json();
dispatch({ type: 'update', payload: { jwt: result.token } }); setAuthToken(result.token);
navigate('/'); navigate('/');
} }
}; };
return ( return (
<div> <div className="flex flex-row justify-center m-4">
<div>Name</div> <div className="w-96">
<input <Paper>
type="text" <div>Name</div>
value={name} <Input type="text" value={name} onChange={(e) => setName(e.target.value)} />
onChange={(e) => setName(e.target.value)} <div>Email</div>
/> <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<div>Email</div> <div>Confirm Email</div>
<input <Input type="email" value={emailConfirmation} onChange={(e) => setEmailConfirmation(e.target.value)} />
type="email" <div>Password</div>
value={email} <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
onChange={(e) => setEmail(e.target.value)} <div>Confirm Password</div>
/> <Input
<div>Confirm Email</div> type="password"
<input value={passwordConfiramation}
type="email" onChange={(e) => setPasswordConfirmation(e.target.value)}
value={emailConfirmation} />
onChange={(e) => setEmailConfirmation(e.target.value)} <div className="flex flex-row justify-end mt-2">
/> <Button type="button" onClick={signUp}>
<div>Password</div> Sign up
<input </Button>
type="password" </div>
value={password} <div className="flex">{errorMessage}</div>
onChange={(e) => setPassword(e.target.value)} </Paper>
/>
<div>Confirm Password</div>
<input
type="password"
value={passwordConfiramation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
/>
<div>
<button type="button" onClick={signUp}>Sign up</button>
</div> </div>
</div> </div>
); );

View File

@ -2,10 +2,18 @@
"name": "nestclientstarterapp-client", "name": "nestclientstarterapp-client",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"scripts": {}, "scripts": {
"watch": "parcel watch ./index.js --dist-dir ../static",
"build": "parcel build ./index.js --dist-dir ../static"
},
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": {}, "devDependencies": {
"autoprefixer": "^10.4.0",
"parcel": "^2.0.1",
"postcss": "^8.3.11",
"tailwindcss": "^2.2.19"
},
"dependencies": { "dependencies": {
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",

View File

@ -0,0 +1,9 @@
module.exports = {
mode: 'jit',
purge: ['*/**/*.jsx'],
theme: {
extend: {},
},
variants: {},
plugins: [],
};

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const ApiContext = createContext({});

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const AuthContext = createContext({});

View File

@ -1,3 +0,0 @@
import { createContext } from 'react';
export const SettingsContext = createContext({});

37
client/utils/use_api.js Normal file
View File

@ -0,0 +1,37 @@
import { useRef } from 'react';
export const useApi = (authToken) => {
const apiRef = useRef(new Api());
apiRef.current.authToken = authToken;
return apiRef.current;
};
export class Api {
authToken = null;
makeRequest(url, method, body) {
return fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.authToken}`,
},
body,
}).then((res) => res.json());
}
get(url) {
return this.makeRequest(url, 'GET');
}
post(url, body = {}) {
return this.makeRequest(url, 'POST', body);
}
put(url, body = {}) {
return this.makeRequest(url, 'PUT', body);
}
del(url) {
return this.makeRequest(url, 'DELETE');
}
}

View File

@ -0,0 +1,19 @@
import { useEffect, useRef } from 'react';
export const useJwtRefresh = (authToken, setAuthToken) => {
const refreshTimer = useRef(null);
useEffect(() => {
clearTimeout(refreshTimer.current);
if (authToken) {
refreshTimer.current = setTimeout(async () => {
const result = await fetch('/refresh_token').then((res) => res.json());
if (result.token) {
setAuthToken(result.token);
} else {
setAuthToken(null);
}
}, 60000 * 10); // 10 minutes
}
return () => clearTimeout(refreshTimer.current);
}, [authToken]);
};

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config server/database/cli_config.ts", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config server/database/cli_config.ts",
"db:start": "pg_ctl status || pg_ctl start", "db:start": "pg_ctl status || pg_ctl start",
"db:stop": "pg_ctl status && pg_ctl stop", "db:stop": "pg_ctl status && pg_ctl stop",
"db:migration:create": "cd server/migrations && typeorm migration:create -n ", "db:migration:create": "cd server/database/migrations && typeorm migration:create -n ",
"db:migrate": "yarn db:start && yarn typeorm migration:run", "db:migrate": "yarn db:start && yarn typeorm migration:run",
"db:migrate:undo": "yarn db:start && yarn typeorm migration:revert", "db:migrate:undo": "yarn db:start && yarn typeorm migration:revert",
"prebuild": "rimraf dist", "prebuild": "rimraf dist",
@ -26,8 +26,8 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"client:build": "parcel build ./client/index.js --dist-dir static", "client:build": "cd client && yarn build",
"client:watch": "parcel watch ./client/index.js --dist-dir static" "client:watch": "cd client && yarn watch"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^8.0.0", "@nestjs/common": "^8.0.0",
@ -62,7 +62,6 @@
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0", "eslint-plugin-prettier": "^3.4.0",
"jest": "^27.0.6", "jest": "^27.0.6",
"parcel": "^2.0.1",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"purdy": "^3.5.1", "purdy": "^3.5.1",
"repl": "^0.1.3", "repl": "^0.1.3",

View File

@ -1,6 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => { describe('AppController', () => {
let appController: AppController; let appController: AppController;
@ -8,15 +7,15 @@ describe('AppController', () => {
beforeEach(async () => { beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({ const app: TestingModule = await Test.createTestingModule({
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [],
}).compile(); }).compile();
appController = app.get<AppController>(AppController); appController = app.get<AppController>(AppController);
}); });
describe('root', () => { // describe('root', () => {
it('should return "Hello World!"', () => { // it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!'); // expect(appController.getHello()).toBe('Hello World!');
}); // });
}); // });
}); });

View File

@ -1,12 +1,8 @@
import { Controller, Get, Render, Req } from '@nestjs/common'; import { Controller, Get, Render } from '@nestjs/common';
import { Request } from 'express';
@Controller() @Controller()
export class AppController { export class AppController {
@Get() @Get()
@Render('index') @Render('index')
index(@Req() req: Request) { index() {}
const jwt = req.cookies['_token'];
return { jwt };
}
} }

View File

@ -1,13 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service';
import { config } from './database/config'; import { config } from './database/config';
import { UsersModule } from './modules/users.module'; import { UsersModule } from './modules/users.module';
import { JwtService } from './providers/services/jwt.service';
@Module({ @Module({
imports: [TypeOrmModule.forRoot(config), UsersModule], imports: [TypeOrmModule.forRoot(config), UsersModule],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [JwtService],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,32 @@
import { Body, Controller, Get, HttpException, Req } from '@nestjs/common';
import { Request } from 'express';
import { UsersService } from 'server/providers/services/users.service';
import { SignInDto } from 'server/dto/sign_in.dto';
import { RefreshTokenBody } from 'server/dto/refresh_token_body.dto';
import { JwtService } from 'server/providers/services/jwt.service';
// this is kind of a misnomer because we are doing token based auth
// instead of session based auth
@Controller()
export class RefreshTokensController {
constructor(private usersService: UsersService, private jwtService: JwtService) {}
@Get('/refresh_token')
async get(@Body() body: SignInDto, @Req() req: Request) {
const refreshToken: string = req.cookies['_refresh_token'];
if (!refreshToken) {
throw new HttpException('No refresh token present', 401);
}
const tokenBody = this.jwtService.parseRefreshToken(refreshToken) as RefreshTokenBody;
const user = await this.usersService.find(tokenBody.userId, ['refreshTokens']);
const userRefreshToken = user.refreshTokens.find((t) => t.id === tokenBody.id);
if (!userRefreshToken) {
throw new HttpException('User refresh token not found', 401);
}
const token = this.jwtService.issueToken({ userId: user.id });
return { token };
}
}

View File

@ -1,56 +1,53 @@
import { import { Body, Controller, Delete, HttpException, HttpStatus, Post, Res } from '@nestjs/common';
Body,
Controller,
Delete,
HttpException,
HttpStatus,
Post,
Redirect,
Res,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import * as jwt from 'jsonwebtoken';
import { UsersService } from 'server/providers/services/users.service'; import { UsersService } from 'server/providers/services/users.service';
import { SignInDto } from 'server/dto/sign_in.dto'; import { SignInDto } from 'server/dto/sign_in.dto';
import { JwtService } from 'server/providers/services/jwt.service';
import { RefreshTokensService } from 'server/providers/services/refresh_tokens.service';
import { RefreshToken } from 'server/entities/refresh_token.entity';
// this is kind of a misnomer because we are doing token based auth // this is kind of a misnomer because we are doing token based auth
// instead of session based auth // instead of session based auth
@Controller() @Controller()
export class SessionsController { export class SessionsController {
constructor(private usersService: UsersService) {} constructor(
private usersService: UsersService,
private jwtService: JwtService,
private refreshTokenService: RefreshTokensService,
) {}
@Post('/sessions') @Post('/sessions')
async create( async create(@Body() body: SignInDto, @Res({ passthrough: true }) res: Response) {
@Body() body: SignInDto, const { verified, user } = await this.usersService.verify(body.email, body.password);
@Res({ passthrough: true }) res: Response,
) {
const { verified, user } = await this.usersService.verify(
body.email,
body.password,
);
if (!verified) { if (!verified) {
throw new HttpException( throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST);
'Invalid email or password.',
HttpStatus.BAD_REQUEST,
);
} }
// Write JWT to cookie and send with response.
const token = jwt.sign( let refreshToken = user.refreshTokens[0];
{ if (!refreshToken) {
user_id: user.id, const newRefreshToken = new RefreshToken();
}, newRefreshToken.user = user;
process.env.ENCRYPTION_KEY, refreshToken = await this.refreshTokenService.create(newRefreshToken);
{ expiresIn: '1h' }, // generate new refresh token
); }
res.cookie('_token', token);
// JWT gets sent with response
const token = this.jwtService.issueToken({ userId: user.id });
const refreshJwtToken = this.jwtService.issueRefreshToken({ id: refreshToken.id, userId: user.id });
// only refresh token should go in the cookie
res.cookie('_refresh_token', refreshJwtToken, {
httpOnly: true, // prevents javascript code from accessing cookie (helps protect against XSS attacks)
});
return { token }; return { token };
} }
@Delete('/sessions') @Delete('/sessions')
async destroy(@Res({ passthrough: true }) res: Response) { async destroy(@Res({ passthrough: true }) res: Response) {
res.clearCookie('_token'); res.clearCookie('_refresh_token');
return { success: true }; return { success: true };
} }
} }

View File

@ -1,50 +1,57 @@
import { import { Body, Controller, Get, HttpException, HttpStatus, Post, Res, UseGuards } from '@nestjs/common';
Body,
Controller,
HttpException,
HttpStatus,
Post,
Res,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { Response } from 'express'; import { Response } from 'express';
import * as jwt from 'jsonwebtoken'; import { JwtBody } from 'server/decorators/jwt_body.decorator';
import { CreateUserDto } from 'server/dto/create_user.dto'; import { CreateUserDto } from 'server/dto/create_user.dto';
import { JwtBodyDto } from 'server/dto/jwt_body.dto';
import { RefreshToken } from 'server/entities/refresh_token.entity';
import { User } from 'server/entities/user.entity'; import { User } from 'server/entities/user.entity';
import { AuthGuard } from 'server/providers/guards/auth.guard';
import { JwtService } from 'server/providers/services/jwt.service';
import { RefreshTokensService } from 'server/providers/services/refresh_tokens.service';
import { UsersService } from 'server/providers/services/users.service'; import { UsersService } from 'server/providers/services/users.service';
@Controller() @Controller()
export class UsersController { export class UsersController {
constructor(private usersService: UsersService) {} constructor(
private usersService: UsersService,
private jwtService: JwtService,
private refreshTokenService: RefreshTokensService,
) {}
@Get('/users/me')
@UseGuards(AuthGuard)
async getCurrentUser(@JwtBody() jwtBody: JwtBodyDto) {
const user = await this.usersService.find(jwtBody.userId);
return { user };
}
@Post('/users') @Post('/users')
async create( async create(@Body() userPayload: CreateUserDto, @Res({ passthrough: true }) res: Response) {
@Body() userPayload: CreateUserDto,
@Res({ passthrough: true }) res: Response,
) {
const newUser = new User(); const newUser = new User();
newUser.email = userPayload.email; newUser.email = userPayload.email;
newUser.name = userPayload.name; newUser.name = userPayload.name;
newUser.password_hash = await bcrypt.hash(userPayload.password, 10); newUser.passwordHash = await bcrypt.hash(userPayload.password, 10);
try { try {
const user = await this.usersService.create(newUser); const user = await this.usersService.create(newUser);
// assume signup and write cookie // create refresh token in database for user
// Write JWT to cookie and send with response. const newRefreshToken = new RefreshToken();
const token = jwt.sign( newRefreshToken.user = user;
{ const refreshToken = await this.refreshTokenService.create(newRefreshToken);
user_id: user.id,
}, // issue jwt and refreshJwtToken
process.env.ENCRYPTION_KEY, const token = this.jwtService.issueToken({ userId: user.id });
{ expiresIn: '1h' }, const refreshJwtToken = this.jwtService.issueRefreshToken({ id: refreshToken.id, userId: user.id });
);
res.cookie('_token', token); // only refresh token should go in the cookie
res.cookie('_refresh_token', refreshJwtToken, {
httpOnly: true, // prevents javascript code from accessing cookie (helps protect against XSS attacks)
});
return { user, token }; return { user, token };
} catch (e) { } catch (e) {
throw new HttpException( throw new HttpException(`User creation failed. ${e.message}`, HttpStatus.BAD_REQUEST);
`User creation failed. ${e.message}`,
HttpStatus.BAD_REQUEST,
);
} }
} }
} }

View File

@ -18,7 +18,7 @@ export class AddUser1637028716848 implements MigrationInterface {
isNullable: false, isNullable: false,
}, },
{ {
name: 'password_hash', name: 'passwordHash',
type: 'text', type: 'text',
isNullable: false, isNullable: false,
}, },

View File

@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class AddRefreshToken1637631042877 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'refresh_token',
columns: [
{
name: 'id',
type: 'int',
isPrimary: true,
isGenerated: true,
},
{
name: 'userId',
type: 'int',
isNullable: false,
},
],
}),
);
await queryRunner.createForeignKey(
'refresh_token',
new TableForeignKey({
columnNames: ['userId'],
referencedColumnNames: ['id'],
referencedTableName: 'user',
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('refresh_token');
}
}

View File

@ -0,0 +1,6 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const JwtBody = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.jwtBody;
});

View File

@ -0,0 +1,3 @@
export interface JwtBodyDto {
userId: number;
}

View File

@ -0,0 +1,4 @@
export interface RefreshTokenBody {
id: number;
userId: number;
}

View File

@ -0,0 +1,11 @@
import { Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { User } from './user.entity';
@Entity()
export class RefreshToken {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => User, (user) => user.refreshTokens)
user: User;
}

View File

@ -1,4 +1,5 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { RefreshToken } from './refresh_token.entity';
@Entity() @Entity()
export class User { export class User {
@ -12,5 +13,8 @@ export class User {
name: string; name: string;
@Column({ nullable: false }) @Column({ nullable: false })
password_hash: string; passwordHash: string;
@OneToMany(() => RefreshToken, (token) => token.user)
refreshTokens: RefreshToken[];
} }

View File

@ -21,7 +21,7 @@ async function bootstrap() {
app.use(cookieParser()); app.use(cookieParser());
app.useStaticAssets(join(__dirname, '..', 'static')); app.useStaticAssets(join(__dirname, '..', 'static'));
app.setBaseViewsDir(join(__dirname, '../', 'views')); app.setBaseViewsDir(join(__dirname, '..', 'views'));
app.setViewEngine('hbs'); app.setViewEngine('hbs');
await app.listen(process.env.PORT); await app.listen(process.env.PORT);
} }

View File

@ -4,10 +4,14 @@ import { User } from 'server/entities/user.entity';
import { SessionsController } from '../controllers/sessions.controller'; import { SessionsController } from '../controllers/sessions.controller';
import { UsersController } from 'server/controllers/users.controller'; import { UsersController } from 'server/controllers/users.controller';
import { UsersService } from '../providers/services/users.service'; import { UsersService } from '../providers/services/users.service';
import { RefreshTokensService } from '../providers/services/refresh_tokens.service';
import { RefreshToken } from 'server/entities/refresh_token.entity';
import { JwtService } from 'server/providers/services/jwt.service';
import { RefreshTokensController } from 'server/controllers/refresh_tokens.controller';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User])], imports: [TypeOrmModule.forFeature([User, RefreshToken])],
controllers: [SessionsController, UsersController], controllers: [SessionsController, UsersController, RefreshTokensController],
providers: [UsersService], providers: [UsersService, RefreshTokensService, JwtService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@ -0,0 +1,20 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { JwtService } from '../services/jwt.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
const jwt = authHeader.split(' ')[1];
try {
req.jwtBody = this.jwtService.parseToken(jwt);
} catch (e) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,27 @@
import { HttpException, Injectable } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { JwtBodyDto } from 'server/dto/jwt_body.dto';
import { RefreshTokenBody } from 'server/dto/refresh_token_body.dto';
@Injectable()
export class JwtService {
issueToken(body: JwtBodyDto | RefreshTokenBody, expiresIn = '15m', key = process.env.ENCRYPTION_KEY): string {
return jwt.sign(body, key, { expiresIn });
}
issueRefreshToken(body: RefreshTokenBody) {
return this.issueToken(body, '1y', process.env.REFRESH_ENCRYPTION_KEY);
}
parseToken(token: string, key = process.env.ENCRYPTION_KEY): JwtBodyDto | RefreshTokenBody {
try {
return jwt.verify(token, key);
} catch (e) {
throw new HttpException('Invalid jwt token', 401);
}
}
parseRefreshToken(token: string) {
return this.parseToken(token, process.env.REFRESH_ENCRYPTION_KEY);
}
}

View File

@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RefreshToken } from 'server/entities/refresh_token.entity';
@Injectable()
export class RefreshTokensService {
constructor(
@InjectRepository(RefreshToken)
private refreshTokenRespository: Repository<RefreshToken>,
) {}
create(refreshToken: RefreshToken) {
return this.refreshTokenRespository.save(refreshToken);
}
destroy(refreshToken: RefreshToken) {
return this.refreshTokenRespository.remove(refreshToken);
}
}

View File

@ -11,12 +11,12 @@ export class UsersService {
private usersRespository: Repository<User>, private usersRespository: Repository<User>,
) {} ) {}
findBy(options: Record<string, any>) { findBy(options: Record<string, any>, relations: string[] = []) {
return this.usersRespository.findOne(options); return this.usersRespository.findOne(options, { relations });
} }
find(id: number) { find(id: number, relations: string[] = []) {
return this.usersRespository.findOne(id); return this.usersRespository.findOne(id, { relations });
} }
create(user: User) { create(user: User) {
@ -24,12 +24,9 @@ export class UsersService {
} }
async verify(email: string, password: string) { async verify(email: string, password: string) {
const user = await this.usersRespository.findOne({ email }); const user = await this.usersRespository.findOne({ email }, { relations: ['refreshTokens'] });
if (!user) return { verified: false, user: null }; if (!user) return { verified: false, user: null };
const verified: boolean = await bcrypt.compare( const verified: boolean = await bcrypt.compare(password, user.passwordHash);
password,
user.password_hash,
);
return { verified, user: verified ? user : null }; return { verified, user: verified ? user : null };
} }
} }

View File

@ -1,10 +1,6 @@
<html> <html>
<head> <head>
<script type="text/javascript"> <link rel="stylesheet" href="index.css" />
window.SETTINGS = {
jwt: '{{jwt}}'
};
</script>
</head> </head>
<body> <body>
<div id="app" /> <div id="app" />

3223
yarn.lock

File diff suppressed because it is too large Load Diff