adds api, guard, tailwind
This commit is contained in:
parent
4ae4e87468
commit
8d0b32f8df
@ -7,3 +7,4 @@ DATABASE_URL=neststarterappdevelopement
|
||||
|
||||
# recommend using randomkeygen.com to generate a key
|
||||
ENCRYPTION_KEY=yourencryptionkey
|
||||
REFRESH_ENCRYPTION_KEY=yourrefreshencryptionkey
|
6
client/.postcssrc
Normal file
6
client/.postcssrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {},
|
||||
}
|
||||
}
|
5
client/app.css
Normal file
5
client/app.css
Normal file
@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
|
||||
@tailwind components;
|
||||
|
||||
@tailwind utilities;
|
@ -1,25 +1,40 @@
|
||||
import { useReducer } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { Router } from './components/router';
|
||||
import { SettingsContext } from './utils/settings_context';
|
||||
|
||||
const settingsReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'update': {
|
||||
return { ...state, ...action.payload };
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
import { ApiContext } from './utils/api_context';
|
||||
import { AuthContext } from './utils/auth_context';
|
||||
import { useApi } from './utils/use_api';
|
||||
import { useJwtRefresh } from './utils/use_jwt_refresh';
|
||||
import './app.css';
|
||||
|
||||
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 (
|
||||
<SettingsContext.Provider value={[settings, dispatch]}>
|
||||
<AuthContext.Provider value={[authToken, setAuthToken]}>
|
||||
<ApiContext.Provider value={api}>
|
||||
<HashRouter>
|
||||
<Router />
|
||||
</HashRouter>
|
||||
</SettingsContext.Provider>
|
||||
</ApiContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
7
client/components/common/button.jsx
Normal file
7
client/components/common/button.jsx
Normal 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>
|
||||
);
|
||||
};
|
3
client/components/common/input.jsx
Normal file
3
client/components/common/input.jsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const Input = (props) => {
|
||||
return <input className="border-2 rounded-lg p-1" {...props} />;
|
||||
};
|
3
client/components/common/paper.jsx
Normal file
3
client/components/common/paper.jsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const Paper = ({ children }) => {
|
||||
return <div className="shadow-md flex flex-col p-4">{children}</div>;
|
||||
};
|
@ -1,20 +1,33 @@
|
||||
import { useContext } from 'react';
|
||||
import { SettingsContext } from '../../utils/settings_context';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { ApiContext } from '../../utils/api_context';
|
||||
import { AuthContext } from '../../utils/auth_context';
|
||||
|
||||
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 res = await fetch('/sessions', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.status === 200) {
|
||||
dispatch({ type: 'update', payload: { jwt: undefined } });
|
||||
const res = await api.del('/sessions');
|
||||
if (res.success) {
|
||||
setAuthToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome</h1>
|
||||
<h1>Welcome {user.name}</h1>
|
||||
<button type="button" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { useContext } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
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 { SignUp } from './sign_up/_sign_up';
|
||||
|
||||
export const Router = () => {
|
||||
const [settings] = useContext(SettingsContext);
|
||||
const { jwt } = settings;
|
||||
const [authToken] = useContext(AuthContext);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
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="signup" element={<SignUp />} />
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { useContext, useState } from 'react';
|
||||
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 = () => {
|
||||
const [, dispatch] = useContext(SettingsContext);
|
||||
const [, setAuthToken] = useContext(AuthContext);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
@ -25,7 +28,7 @@ export const SignIn = () => {
|
||||
});
|
||||
if (res.status === 201) {
|
||||
const result = await res.json();
|
||||
dispatch({ type: 'update', payload: { jwt: result.token } });
|
||||
setAuthToken(result.token);
|
||||
navigate('/');
|
||||
} else {
|
||||
console.error('An issue occurred when logging in.');
|
||||
@ -33,28 +36,23 @@ export const SignIn = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row justify-center m-4">
|
||||
<div className="w-96">
|
||||
<Paper>
|
||||
<div>Email</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<div>Password</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<button type="button" onClick={signIn}>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onClick={goToSignUp}>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<div className="flex flex-row justify-end mt-2">
|
||||
<Button type="button" onClick={goToSignUp}>
|
||||
Sign up
|
||||
</button>
|
||||
</Button>
|
||||
<div className="pl-2" />
|
||||
<Button type="button" onClick={signIn}>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { useContext, useState } from 'react';
|
||||
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 = () => {
|
||||
const [, dispatch] = useContext(SettingsContext);
|
||||
const [, setAuthToken] = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@ -46,45 +49,36 @@ export const SignUp = () => {
|
||||
});
|
||||
if (res.status === 201) {
|
||||
const result = await res.json();
|
||||
dispatch({ type: 'update', payload: { jwt: result.token } });
|
||||
setAuthToken(result.token);
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row justify-center m-4">
|
||||
<div className="w-96">
|
||||
<Paper>
|
||||
<div>Name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input type="text" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div>Email</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<div>Confirm Email</div>
|
||||
<input
|
||||
type="email"
|
||||
value={emailConfirmation}
|
||||
onChange={(e) => setEmailConfirmation(e.target.value)}
|
||||
/>
|
||||
<Input type="email" value={emailConfirmation} onChange={(e) => setEmailConfirmation(e.target.value)} />
|
||||
<div>Password</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<div>Confirm Password</div>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordConfiramation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<button type="button" onClick={signUp}>Sign up</button>
|
||||
<div className="flex flex-row justify-end mt-2">
|
||||
<Button type="button" onClick={signUp}>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex">{errorMessage}</div>
|
||||
</Paper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -2,10 +2,18 @@
|
||||
"name": "nestclientstarterapp-client",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {},
|
||||
"scripts": {
|
||||
"watch": "parcel watch ./index.js --dist-dir ../static",
|
||||
"build": "parcel build ./index.js --dist-dir ../static"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.0",
|
||||
"parcel": "^2.0.1",
|
||||
"postcss": "^8.3.11",
|
||||
"tailwindcss": "^2.2.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
|
9
client/tailwind.config.js
Normal file
9
client/tailwind.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
purge: ['*/**/*.jsx'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
};
|
3
client/utils/api_context.js
Normal file
3
client/utils/api_context.js
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const ApiContext = createContext({});
|
3
client/utils/auth_context.js
Normal file
3
client/utils/auth_context.js
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const AuthContext = createContext({});
|
@ -1,3 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const SettingsContext = createContext({});
|
37
client/utils/use_api.js
Normal file
37
client/utils/use_api.js
Normal 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');
|
||||
}
|
||||
}
|
19
client/utils/use_jwt_refresh.js
Normal file
19
client/utils/use_jwt_refresh.js
Normal 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]);
|
||||
};
|
4897
client/yarn.lock
4897
client/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,7 @@
|
||||
"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: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:undo": "yarn db:start && yarn typeorm migration:revert",
|
||||
"prebuild": "rimraf dist",
|
||||
@ -26,8 +26,8 @@
|
||||
"test:cov": "jest --coverage",
|
||||
"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",
|
||||
"client:build": "parcel build ./client/index.js --dist-dir static",
|
||||
"client:watch": "parcel watch ./client/index.js --dist-dir static"
|
||||
"client:build": "cd client && yarn build",
|
||||
"client:watch": "cd client && yarn watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^8.0.0",
|
||||
@ -62,7 +62,6 @@
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"jest": "^27.0.6",
|
||||
"parcel": "^2.0.1",
|
||||
"prettier": "^2.4.1",
|
||||
"purdy": "^3.5.1",
|
||||
"repl": "^0.1.3",
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
@ -8,15 +7,15 @@ describe('AppController', () => {
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
// describe('root', () => {
|
||||
// it('should return "Hello World!"', () => {
|
||||
// expect(appController.getHello()).toBe('Hello World!');
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
@ -1,12 +1,8 @@
|
||||
import { Controller, Get, Render, Req } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Controller, Get, Render } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@Get()
|
||||
@Render('index')
|
||||
index(@Req() req: Request) {
|
||||
const jwt = req.cookies['_token'];
|
||||
return { jwt };
|
||||
}
|
||||
index() {}
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { config } from './database/config';
|
||||
import { UsersModule } from './modules/users.module';
|
||||
import { JwtService } from './providers/services/jwt.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forRoot(config), UsersModule],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [JwtService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
32
server/controllers/refresh_tokens.controller.ts
Normal file
32
server/controllers/refresh_tokens.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
@ -1,56 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Redirect,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Body, Controller, Delete, HttpException, HttpStatus, Post, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { UsersService } from 'server/providers/services/users.service';
|
||||
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
|
||||
// instead of session based auth
|
||||
@Controller()
|
||||
export class SessionsController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private refreshTokenService: RefreshTokensService,
|
||||
) {}
|
||||
|
||||
@Post('/sessions')
|
||||
async create(
|
||||
@Body() body: SignInDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { verified, user } = await this.usersService.verify(
|
||||
body.email,
|
||||
body.password,
|
||||
);
|
||||
async create(@Body() body: SignInDto, @Res({ passthrough: true }) res: Response) {
|
||||
const { verified, user } = await this.usersService.verify(body.email, body.password);
|
||||
|
||||
if (!verified) {
|
||||
throw new HttpException(
|
||||
'Invalid email or password.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
// Write JWT to cookie and send with response.
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
},
|
||||
process.env.ENCRYPTION_KEY,
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
res.cookie('_token', token);
|
||||
|
||||
let refreshToken = user.refreshTokens[0];
|
||||
if (!refreshToken) {
|
||||
const newRefreshToken = new RefreshToken();
|
||||
newRefreshToken.user = user;
|
||||
refreshToken = await this.refreshTokenService.create(newRefreshToken);
|
||||
// generate new refresh 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 };
|
||||
}
|
||||
|
||||
@Delete('/sessions')
|
||||
async destroy(@Res({ passthrough: true }) res: Response) {
|
||||
res.clearCookie('_token');
|
||||
res.clearCookie('_refresh_token');
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
@ -1,50 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpException, HttpStatus, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
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 { JwtBodyDto } from 'server/dto/jwt_body.dto';
|
||||
import { RefreshToken } from 'server/entities/refresh_token.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';
|
||||
|
||||
@Controller()
|
||||
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')
|
||||
async create(
|
||||
@Body() userPayload: CreateUserDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
async create(@Body() userPayload: CreateUserDto, @Res({ passthrough: true }) res: Response) {
|
||||
const newUser = new User();
|
||||
newUser.email = userPayload.email;
|
||||
newUser.name = userPayload.name;
|
||||
newUser.password_hash = await bcrypt.hash(userPayload.password, 10);
|
||||
newUser.passwordHash = await bcrypt.hash(userPayload.password, 10);
|
||||
|
||||
try {
|
||||
const user = await this.usersService.create(newUser);
|
||||
// assume signup and write cookie
|
||||
// Write JWT to cookie and send with response.
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
},
|
||||
process.env.ENCRYPTION_KEY,
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
res.cookie('_token', token);
|
||||
// create refresh token in database for user
|
||||
const newRefreshToken = new RefreshToken();
|
||||
newRefreshToken.user = user;
|
||||
const refreshToken = await this.refreshTokenService.create(newRefreshToken);
|
||||
|
||||
// issue jwt and refreshJwtToken
|
||||
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 { user, token };
|
||||
} catch (e) {
|
||||
throw new HttpException(
|
||||
`User creation failed. ${e.message}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
throw new HttpException(`User creation failed. ${e.message}`, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export class AddUser1637028716848 implements MigrationInterface {
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'password_hash',
|
||||
name: 'passwordHash',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
|
38
server/database/migrations/1637631042877-AddRefreshToken.ts
Normal file
38
server/database/migrations/1637631042877-AddRefreshToken.ts
Normal 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');
|
||||
}
|
||||
}
|
6
server/decorators/jwt_body.decorator.ts
Normal file
6
server/decorators/jwt_body.decorator.ts
Normal 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;
|
||||
});
|
3
server/dto/jwt_body.dto.ts
Normal file
3
server/dto/jwt_body.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface JwtBodyDto {
|
||||
userId: number;
|
||||
}
|
4
server/dto/refresh_token_body.dto.ts
Normal file
4
server/dto/refresh_token_body.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface RefreshTokenBody {
|
||||
id: number;
|
||||
userId: number;
|
||||
}
|
11
server/entities/refresh_token.entity.ts
Normal file
11
server/entities/refresh_token.entity.ts
Normal 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;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
|
||||
import { RefreshToken } from './refresh_token.entity';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@ -12,5 +13,8 @@ export class User {
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
password_hash: string;
|
||||
passwordHash: string;
|
||||
|
||||
@OneToMany(() => RefreshToken, (token) => token.user)
|
||||
refreshTokens: RefreshToken[];
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ async function bootstrap() {
|
||||
|
||||
app.use(cookieParser());
|
||||
app.useStaticAssets(join(__dirname, '..', 'static'));
|
||||
app.setBaseViewsDir(join(__dirname, '../', 'views'));
|
||||
app.setBaseViewsDir(join(__dirname, '..', 'views'));
|
||||
app.setViewEngine('hbs');
|
||||
await app.listen(process.env.PORT);
|
||||
}
|
||||
|
@ -4,10 +4,14 @@ import { User } from 'server/entities/user.entity';
|
||||
import { SessionsController } from '../controllers/sessions.controller';
|
||||
import { UsersController } from 'server/controllers/users.controller';
|
||||
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({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [SessionsController, UsersController],
|
||||
providers: [UsersService],
|
||||
imports: [TypeOrmModule.forFeature([User, RefreshToken])],
|
||||
controllers: [SessionsController, UsersController, RefreshTokensController],
|
||||
providers: [UsersService, RefreshTokensService, JwtService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
20
server/providers/guards/auth.guard.ts
Normal file
20
server/providers/guards/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
27
server/providers/services/jwt.service.ts
Normal file
27
server/providers/services/jwt.service.ts
Normal 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);
|
||||
}
|
||||
}
|
20
server/providers/services/refresh_tokens.service.ts
Normal file
20
server/providers/services/refresh_tokens.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -11,12 +11,12 @@ export class UsersService {
|
||||
private usersRespository: Repository<User>,
|
||||
) {}
|
||||
|
||||
findBy(options: Record<string, any>) {
|
||||
return this.usersRespository.findOne(options);
|
||||
findBy(options: Record<string, any>, relations: string[] = []) {
|
||||
return this.usersRespository.findOne(options, { relations });
|
||||
}
|
||||
|
||||
find(id: number) {
|
||||
return this.usersRespository.findOne(id);
|
||||
find(id: number, relations: string[] = []) {
|
||||
return this.usersRespository.findOne(id, { relations });
|
||||
}
|
||||
|
||||
create(user: User) {
|
||||
@ -24,12 +24,9 @@ export class UsersService {
|
||||
}
|
||||
|
||||
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 };
|
||||
const verified: boolean = await bcrypt.compare(
|
||||
password,
|
||||
user.password_hash,
|
||||
);
|
||||
const verified: boolean = await bcrypt.compare(password, user.passwordHash);
|
||||
return { verified, user: verified ? user : null };
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
window.SETTINGS = {
|
||||
jwt: '{{jwt}}'
|
||||
};
|
||||
</script>
|
||||
<link rel="stylesheet" href="index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" />
|
||||
|
Loading…
Reference in New Issue
Block a user