First commit. Basic functionality for create users and devices

parent 730feb7a
node_modules
package-lock.json
yarn.lock
# Editors and IDEs
.vs
.idea
MIT License
Copyright (c) 2019 Makers Bierzo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# Cleair Server
# Cleair (server)
Servidor web de Cleair
Servicio web que implementa la API de Cleair
\ No newline at end of file
## Dependencies
- [NodeJS](https://nodejs.org) (>= 11)
- [Mongo Database](https://www.mongodb.com) (>= 4)
\ No newline at end of file
*.cert
*.key
!localhost.cert
!localhost.key
{
"jwt": {
"public": "config/localhost.cert",
"private": "config/localhost.key"
}
}
-----BEGIN CERTIFICATE-----
MIIC+zCCAeOgAwIBAgIJANoD1ksWzrDpMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0xOTA1MzAyMjA0NDRaFw0yOTA1MjcyMjA0NDRaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAMj9h2aFQBh8Qv7VQl3+R1R2s025udL+58+zW/YkGGil8oLwDNvHEyZqyrFi
7CebfJ39khAORA/i7bbTh5uf9ZA1XQ5fnnqMIK2mEAph00OrpttmDQ1W0N8ryf2V
72M0HjpkV74fqxqN4WvpLEboWSpL7rCYkaCsm8I17gTNzinW8WJCSVvnv4x2iGLQ
fFKpvuqmhZXNdE/tmevlOQAr8IUuvr2DmiPDVBKNoAcXkVVwRmvLAWkcTrwwsiUm
pJW5bCNyY1X6RE+YMkPlhuH+k6UXhS+A9+m+6UIew0Cqgacm6RUrIhQr/pI1lbM9
SoxXUfPmDqzUqT6QI7S3tuJ8RE0CAwEAAaNQME4wHQYDVR0OBBYEFNW9syeGNZfA
WYaG5v/EJrQ/wv/VMB8GA1UdIwQYMBaAFNW9syeGNZfAWYaG5v/EJrQ/wv/VMAwG
A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAB0yn5r2KWI9FwuoSv0WJmzW
TWUlp4KC12PY+0pjGUALGkFAda5lawnjjXHIOSLjqIclt793pJrLecD0iAKaO0yn
M/TPJjvoTW58G+DJQkTkHTkBK1o3Z+TbQ7jiBCvQWOfW25MdXcYkit17a7wCMBP3
+ZMijhSuD+beKp0MZOUsJkqn79bI8jivb/+DZO4/klhleF6tXw2JhYs9watRT7oi
d16TywzLWh6eRnzI46cooCx3N4mAcfaTTYH0LtmRhNqqnYZ39FKyV/f+0MpuFUBq
n374wiI+w/ayQAZF4Pd0Wc8jrvkO2ikS/ATVsIpPH15VUgYzEBTVyD1lyw30qcs=
-----END CERTIFICATE-----
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAyP2HZoVAGHxC/tVCXf5HVHazTbm50v7nz7Nb9iQYaKXygvAM
28cTJmrKsWLsJ5t8nf2SEA5ED+LtttOHm5/1kDVdDl+eeowgraYQCmHTQ6um22YN
DVbQ3yvJ/ZXvYzQeOmRXvh+rGo3ha+ksRuhZKkvusJiRoKybwjXuBM3OKdbxYkJJ
W+e/jHaIYtB8Uqm+6qaFlc10T+2Z6+U5ACvwhS6+vYOaI8NUEo2gBxeRVXBGa8sB
aRxOvDCyJSaklblsI3JjVfpET5gyQ+WG4f6TpReFL4D36b7pQh7DQKqBpybpFSsi
FCv+kjWVsz1KjFdR8+YOrNSpPpAjtLe24nxETQIDAQABAoIBAAOAl3lr3QAQOkKi
iLOGmMuZ/ene0KQimPt/jpytaFd6fM1XTYIO6ACFX97TcHYIOZWvM3pgJUN5mtbS
vMwzb14B8AsuE17jTjZ4bFMh/UJynUe5cYTH/H/HDZADqtY5tEUkNnszphGiY0k0
GPdrWRJpnFErd24rU9OAiRNAWtVTx5siVUmzWrpBfPhvdtcReG/tHWSJ19qJnO3a
pYs5yKz5vcDxeMvAaUNCOSAzAF6JTQIx6svia1HpsEeEDNBSZWFyxv0HecwXf8zC
W4vNQAw8N3X7QiCZGCOEcvAB25v9X6XHRkAlCWqND0Fa8EgJ2FctP/BazVb9oqg/
IC1EaakCgYEA7WlVY7MDCEhlrqz+BygBbGfzMmE2sp23I0hRHSeAuBkgxu5AGGFV
A2QbFOKN2APRt8g4LSbfQRr/A0gQT4aNmz/whc6C1qTiWpldGd+1/gm5/j1keN58
ttmw6g5rlozymyE18VP/he+9yPaGGEpkHnRQnDIxb6LBN0EfampOJksCgYEA2Los
BJZeHnh5CbMI3mgCjHst35zGMT8cIZMV0BssXQPydL3zwvE8Uox9N6hSALU5VdAz
HHw/6+4U8krbE/eWI2rekrNMprPVstg6WMxN/4eUStMD0DmcmH3RRu+2tcjVyW99
7S6fysXHpaOQuIoakd5GSjerXRLY3a7S75NlgMcCgYAMawoQYPizojXPYTUYYrtQ
VE+gPv2BckZ2Df5QLBTLjGTugt/PZqfvuXjBKuiIeAqsNkjZ88KRwTu9jDNuNXeK
u1l1Zkvptk4wtvzrsYvvccrMxaFPwTN8zP43//EYut6lxqLvsJkZBGVE8cAp3RpH
jYX55ZY4ZKb+oOVnx4+26QKBgQDOvKkIpzeY8WrkabhoD+d+gnI8AJy+lWkMTfZc
0c7726wI2LoSl3PnesRnt1SiAD11MmnPHmcLc8zfY7Cf12mM14EBh6/yxVFwjPr7
gchVnMtEpQ3aL1rFzB85/6jWt4HX6VbCTdt33jqYMvL2VicKRnHekkQRQM1GimIE
zyHgOQKBgQCVOViU75L95cj/W79i0A8R+D8bLaFNjJPiO9mJpVt6fmCxW/b1xvoI
wIrDoDkcGgRnMpyYhtjg0Z9I0yVTL/sNdRDqf38iTLdAlZTp0SEFYqGsI5s39HlG
bPAsFh3BSKE8eYR6KhSzBgJ4f5JrL/ZZqbFHd6Awr3TK8JXcpXih6A==
-----END RSA PRIVATE KEY-----
version: '3'
services:
mongo:
image: "mongo:4"
command: mongod --bind_ip_all
ports:
- 27017:27017
mongo-express:
image: mongo-express
depends_on:
- "mongo"
ports:
- 27080:8081
{
"name": "cleair",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node ./src/index.ts"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bcrypt": "^3.0.0",
"@types/express": "^4.16.1",
"@types/express-validator": "^3.0.0",
"@types/jsonwebtoken": "^8.3.0",
"@types/mongoose": "^5.3.19",
"@types/sha256": "^0.2.0",
"ts-node": "^8.0.2",
"typescript": "^3.3.3333"
},
"dependencies": {
"bcrypt": "^3.0.4",
"express": "^4.16.4",
"express-validator": "^5.3.1",
"jsonwebtoken": "^8.5.0",
"mongoose": "^5.4.15",
"sha256": "^0.2.0"
}
}
import {Router, Request, Response} from 'express';
import {UserModel, UserModelMethods} from '../models/user.model';
import {Controller} from './controller';
import {UnauthorizedHttpException} from '../exceptions/unauthorized-http-exception';
import {BadRequestHttpException} from '../exceptions/bad-request-http-exception';
import * as Bcrypt from 'bcrypt';
import * as JWT from 'jsonwebtoken';
import * as fs from 'fs';
import * as Config from '../../config/config.json';
interface LoginRequest {
username: string;
password: string;
}
export class AuthController extends Controller {
private readonly basePath: string = '/auth';
public readonly router: Router = Router();
private readonly privateKey: Buffer;
constructor() {
super();
this.privateKey = fs.readFileSync(Config.jwt.private);
this.router.post(`${this.basePath}`, Controller.sync((req, res) => this.login(req, res)));
}
private async login(request: Request, response: Response): Promise<void> {
const loginRequest: LoginRequest = request.body;
const user = await UserModel.findOne({ username: loginRequest.username });
if (user) {
if (await Bcrypt.compare(loginRequest.password, user.password)) {
const session = await UserModelMethods.createSession(user.username, request.ip);
JWT.sign(session, this.privateKey, { algorithm: 'RS256'}, (err, jwt) => {
response.send({ token: jwt });
});
} else {
throw new UnauthorizedHttpException("Wrong password");
}
} else {
throw new BadRequestHttpException();
}
}
}
import {HttpException} from '../exceptions/http-exception';
import {InternalServerErrorHttpException} from '../exceptions/internal-server-error-http-exception';
export abstract class Controller {
protected static sync(handler: (req, res, next?) => Promise<any>) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next))
.catch((err) => {
let httpException: HttpException;
if (err && err instanceof HttpException) {
httpException = err;
} else {
console.error(err);
httpException = new InternalServerErrorHttpException(err);
}
res.status(httpException.status);
res.send(httpException);
})
}
}
}
import {Request} from '../http/request';
import {Response, Router} from 'express';
import {DeviceModel, IDevice} from '../models/device.model';
import {AuthMiddleware} from '../middlewares/auth.middleware';
import {AuthenticationType, UserAuthentication} from '../http/authentication';
import {check, validationResult} from 'express-validator/check';
import {Controller} from './controller';
import {BadRequestHttpException} from '../exceptions/bad-request-http-exception';
export class DeviceController extends Controller {
private readonly basePath: string = '/devices';
public readonly router: Router = Router();
constructor() {
super();
this.router.get(`${this.basePath}`, [
AuthMiddleware.onlyAuthenticated(AuthenticationType.User)
], Controller.sync(DeviceController.getAll));
this.router.post(`${this.basePath}`, [
check('position').isString()
], Controller.sync(DeviceController.createOne));
}
private static async getAll(request: Request, response: Response): Promise<void> {
const auth = request.auth as UserAuthentication;
const devices = await DeviceModel.find({owner: auth.user._id});
response.send(devices.map((device) => {
return {
code: device.code,
name: device.name,
position: device.position
};
}));
}
private static async createOne(request: Request, response: Response): Promise<void> {
const validation = validationResult(request);
if (validation.isEmpty()) {
const auth = request.auth as UserAuthentication;
const device: IDevice = request.body;
device.tokens = [];
device.owner = auth.user;
const devices = await DeviceModel.create([device]);
response.send(devices.map(device => {
return {
code: device.code,
name: device.name,
position: device.position,
location: device.location
};
}));
} else {
throw new BadRequestHttpException(validation.array());
}
}
}
import {Request, Response, Router} from 'express';
import * as Sha256 from 'sha256';
import * as BCrypt from 'bcrypt';
import {IUser, UserModel} from '../models/user.model';
import {check, validationResult} from 'express-validator/check';
import {Controller} from './controller';
import {ConflictHttpException} from '../exceptions/conflict-http-exception';
import {BadRequestHttpException} from '../exceptions/bad-request-http-exception';
import {AuthMiddleware} from '../middlewares/auth.middleware';
import {AppRequest} from '../middlewares/app-request';
import {HttpException} from '../exceptions/http-exception';
import {AuthenticationType, UserAuthentication} from '../http/authentication';
export class UserController extends Controller {
private readonly basePath: string = '/users';
public readonly router: Router = Router();
constructor() {
super();
this.router.get(`${this.basePath}`, [
AuthMiddleware.onlyAuthenticatedAdmin(),
Controller.sync(UserController.getAll)
]);
this.router.get(`${this.basePath}/:id`, [
AuthMiddleware.onlyAuthenticated(AuthenticationType.User),
Controller.sync(UserController.getOne)
]);
this.router.post(`${this.basePath}`, [
check('username').isString().isLength({min: 3, max: 32}),
check('password').isString().isLength({min: 64, max: 64}).matches(/^[0-9A-f]{64}$/),
check('email').isEmail(),
Controller.sync(UserController.createOne)
]);
}
private static async getAll(request: AppRequest, response: Response): Promise<void> {
const users = await UserModel.find();
response.send(users.map(user => {
return {
username: user.username,
email: user.email,
validated: user.validated
};
}));
}
private static async getOne(request: AppRequest, response: Response) {
const username = String(request.params.id);
const user = await UserModel.findOne({username: username});
if (user != null) {
const auth = request.auth as UserAuthentication;
if (auth.user.admin) {
response.send({
username: user.username,
email: user.email,
validated: user.validated,
admin: user.admin
});
} else {
response.send({
username: user.username
});
}
} else {
throw new HttpException(`User <${username}> not found`, 404);
}
}
private static async createOne(request: Request, response: Response): Promise<void> {
const validation = validationResult(request);
if (validation.isEmpty()) {
let userData: IUser = request.body;
if (await UserModel.countDocuments({'username': userData.username}) == 0) {
userData.password = await BCrypt.hash(userData.password, 10);
const validationExpire = new Date();
validationExpire.setHours(validationExpire.getHours() + 24);
userData.validated = false;
userData.validation = {token: Sha256(userData.username + validationExpire.toISOString()), expire: validationExpire};
let user = new UserModel(userData);
user = await user.save();
response.send({
username: user.username,
});
} else {
throw new ConflictHttpException('Username already exists');
}
} else {
throw new BadRequestHttpException(validation.array());
}
}
}
import {HttpException} from './http-exception';
export class BadRequestHttpException extends HttpException {
constructor(message: any = 'Bad request') {
super(message, 400);
}
}
import {HttpException} from './http-exception';
export class ConflictHttpException extends HttpException {
constructor(message: any = 'Conflict') {
super(message, 409);
}
}
export class HttpException {
public readonly status: number;
public readonly message: string;
constructor(message: any, code: number = 500) {
let tsMessage: string;
if (typeof message === 'string') {
tsMessage = message;
} else if (message && message.name == "MongoError") {
tsMessage = `Database error. ${message.errmsg}`;
} else {
tsMessage = JSON.stringify(message);
}
this.message = tsMessage;
this.status = code;
}
}
import {HttpException} from './http-exception';
export class InternalServerErrorHttpException extends HttpException {
constructor(message: any = 'Internal Server Error') {
super(message, 500);
}
}
import {HttpException} from './http-exception';
export class UnauthorizedHttpException extends HttpException {
constructor(message: any = 'Unauthorized') {
super(message, 401);
}
}
import {IDevice} from '../models/device.model';
import {IUserSession} from '../models/user-session';
import {IUser, IUserModel} from '../models/user.model';
export enum AuthenticationType {
Device,
User
}
export abstract class Authentication {
public readonly type: AuthenticationType;
protected constructor(type: AuthenticationType) {
this.type = type;
}
public abstract isValid(): Boolean;
}
export class DeviceAuthentication extends Authentication {
public readonly device: IDevice;
constructor(device: IDevice) {
super(AuthenticationType.Device);
this.device = device;
}
isValid(): Boolean {
return this.device !== undefined && this.device !== null;
}
}
export class UserAuthentication extends Authentication {
public readonly session: IUserSession;
public readonly user: IUserModel;
constructor(session: IUserSession, user: IUserModel) {
super(AuthenticationType.User);
this.session = session;
this.user = user;
}
isValid(): Boolean {
return this.session !== undefined && this.session !== null;
}
}
import {Request as ExpressRequest} from 'express-serve-static-core';
import {Authentication} from './authentication';
export interface Request extends ExpressRequest {
readonly auth: Authentication;
}
import {Server} from "./server";
const server = new Server();
server.listen(8000);
import {Request} from 'express';
import {Authentication} from '../http/authentication';
export interface AppRequest extends Request {
readonly auth: Authentication;
}
import * as JWT from 'jsonwebtoken';
import {Request} from '../http/request';
import {DeviceModelMethods} from '../models/device.model';
import {AuthenticationType, DeviceAuthentication, UserAuthentication} from '../http/authentication';
import * as fs from 'fs';
import * as Config from '../../config/config.json';
import {HttpException} from '../exceptions/http-exception';
import {UserModel} from '../models/user.model';
import {IUserSession} from '../models/user-session';
const PublicKey = fs.readFileSync(Config.jwt.public);
export class AuthMiddleware {
public static handler(req, res, next) {
req.auth = null;
if (req.headers.authorization) {
const authHeader = req.headers.authorization.split(' ');
if (authHeader.length === 2 && authHeader[0] === 'Bearer') {
const token = authHeader[1].trim();
const sessionRaw = JWT.verify(token, PublicKey, {algorithms: ['RS256']}) as any;
const session: IUserSession = {
token: sessionRaw.token,
expire: new Date(Date.parse(sessionRaw.expire)),
ip: sessionRaw.ip
};
UserModel.findOne({sessions: {$elemMatch: {token: session.token, expire: {$gte: new Date()}}}}, (err, user) => {
if (err == null && user != null) {
req.auth = new UserAuthentication(session, user);
next();
} else {
throw new HttpException('Authentication required. Token invalid', 401);
}
});
} else if (authHeader.length === 2 && authHeader[0] === 'Device') {
const token = authHeader[1].trim();
DeviceModelMethods.findByToken(token).then((device) => {
req.auth = new DeviceAuthentication(device);
next();
}).catch((err) => {
console.error('Device authentication error', err);
next();
});
}
} else {
next();
}
}
public static onlyAuthenticated(type: AuthenticationType) {
return (req: Request, res, next) => {
if (req.auth && req.auth.type == type && req.auth.isValid()) {
next();
} else {
throw new HttpException('Authentication required', 401);
}
};
}
public static onlyAuthenticatedAdmin() {
return (req: Request, res, next) => {
if (req.auth && req.auth.type == AuthenticationType.User && req.auth.isValid()) {
const userAuth = req.auth as UserAuthentication;
if (userAuth.user.admin) {
next();
} else {
throw new HttpException('This action only can be performed by an admin', 403);
}
} else {
throw new HttpException('Authentication required', 401);
}
};
}
}
import {HttpException} from '../exceptions/http-exception';
export class ErrorMiddleware {
public static handler(err, req, res, next) {
console.error(err);
if (err instanceof HttpException) {
res.status(err.status).send({code: err.status, error: err.message});
} else {
res.status(500).send({code: 500, error: err.stack.split('\n').map(line => line.trim())});
}
}
}
import {Request, Response} from 'express';
export class LogMiddleware {
public static handler(req: Request, res: Response, next) {
console.log(`[${req.ip}] ${req.method} ${req.url}`);
next();
}
}
import {Document, SchemaDefinition} from 'mongoose';
export interface IDeviceToken {
token: string;
expire: Date;
}
export interface IDeviceTokenModel extends IDeviceToken, Document {
}
export const DeviceTokenDefinition: SchemaDefinition = {
token: {type: String, required: true},
expire: {type: Date, required: true},
};
import {Model, model, Schema, Document, SchemaDefinition, DocumentQuery} from 'mongoose';
import {ILocation, LocationDefinition} from './location.model';
import {DeviceTokenDefinition, IDeviceToken} from './device-token.model';
import {IUser} from './user.model';
import {ObjectId} from 'bson';
import {Position} from './position.enum';
const ModelName: string = 'Device';
export interface IDevice {
code: string;
name: string;
location?: ILocation,
position?: Position,
removed: boolean,
tokens: Array<IDeviceToken>,
owner: IUser
}
export interface IDeviceModel extends IDevice, Document {
}
const DeviceDefinition: SchemaDefinition = {
code: {type: String, required: true},
name: {type: String, required: true},
position: {type: String, enum: [Position.Indoor, Position.Outdoor]},
location: LocationDefinition,
removed: {type: Boolean, required: true, default: false},
tokens: [DeviceTokenDefinition],
owner: {type: ObjectId, required: true}
};
const DeviceSchema: Schema = new Schema(DeviceDefinition);
export const DeviceModel: Model<IDeviceModel> = model<IDeviceModel>(ModelName, DeviceSchema);
export class DeviceModelMethods {
public static findByToken(token: string): DocumentQuery<IDeviceModel, IDeviceModel> {
return DeviceModel.findOne({'tokens.token': {$eq: token}});
}
}
import {Document, SchemaDefinition} from 'mongoose';
export interface ILocation {
latitude: number;
longitude: number;
altitude: number;
}
export interface ILocationModel extends ILocation, Document {
}
export const LocationDefinition: SchemaDefinition = {
latitude: {type: Number, required: true},
longitude: {type: Number, required: true},
altitude: {type: Number, required: false}
};
export enum Position {
Outdoor = "outdoor",
Indoor = "indoor"
}
import {Document, SchemaDefinition} from 'mongoose';
export interface IUserSession {
token: string;
expire: Date;
ip: string;
}
export interface IUserSessionModel extends IUserSession, Document {
}