|
|
|
import * as Express from "express";
|
|
|
|
import Router from "express-promise-router";
|
|
|
|
import * as jwt from "jsonwebtoken";
|
|
|
|
|
|
|
|
import ApiError from "@common/ApiError";
|
|
|
|
import { ErrorCode } from "@common/ErrorCode";
|
|
|
|
import {
|
|
|
|
TokenGrantPasswordRequest,
|
|
|
|
TokenGrantRefreshRequest,
|
|
|
|
TokenGrantRequest,
|
|
|
|
TokenGrantResponse,
|
|
|
|
} from "@common/httpApi";
|
|
|
|
import TokenClaims from "@common/TokenClaims";
|
|
|
|
import { User } from "../entities";
|
|
|
|
import { ServerState } from "../state";
|
|
|
|
|
|
|
|
export { TokenClaims };
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
namespace Express {
|
|
|
|
interface Request {
|
|
|
|
token?: TokenClaims;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
|
|
|
if (!JWT_SECRET) {
|
|
|
|
throw new Error("Must specify JWT_SECRET environment variable");
|
|
|
|
}
|
|
|
|
|
|
|
|
const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes
|
|
|
|
const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {number} lifetime in seconds
|
|
|
|
*/
|
|
|
|
function getExpTime(lifetime: number) {
|
|
|
|
return Math.floor(Date.now() / 1000) + lifetime;
|
|
|
|
}
|
|
|
|
|
|
|
|
function signToken(claims: TokenClaims): Promise<string> {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
jwt.sign(claims, JWT_SECRET, (err: Error, encoded: string) => {
|
|
|
|
if (err) {
|
|
|
|
reject(err);
|
|
|
|
} else {
|
|
|
|
resolve(encoded);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
export function verifyToken(token: string): Promise<TokenClaims> {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
|
|
|
if (err) {
|
|
|
|
if (err.name === "TokenExpiredError") {
|
|
|
|
reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err));
|
|
|
|
} else if (err.name === "JsonWebTokenError") {
|
|
|
|
reject(new ApiError("Invalid token", ErrorCode.BadToken, err));
|
|
|
|
} else {
|
|
|
|
reject(err);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
resolve(decoded as any);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function generateAccessToken(user: User, secret: string): Promise<string> {
|
|
|
|
const access_token_claims: TokenClaims = {
|
|
|
|
iss: "sprinklers3",
|
|
|
|
aud: user.id,
|
|
|
|
name: user.name,
|
|
|
|
type: "access",
|
|
|
|
exp: getExpTime(ACCESS_TOKEN_LIFETIME),
|
|
|
|
};
|
|
|
|
|
|
|
|
return signToken(access_token_claims);
|
|
|
|
}
|
|
|
|
|
|
|
|
function generateRefreshToken(user: User, secret: string): Promise<string> {
|
|
|
|
const refresh_token_claims: TokenClaims = {
|
|
|
|
iss: "sprinklers3",
|
|
|
|
aud: user.id,
|
|
|
|
name: user.name,
|
|
|
|
type: "refresh",
|
|
|
|
exp: getExpTime(REFRESH_TOKEN_LIFETIME),
|
|
|
|
};
|
|
|
|
|
|
|
|
return signToken(refresh_token_claims);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function authentication(state: ServerState) {
|
|
|
|
|
|
|
|
const router = Router();
|
|
|
|
|
|
|
|
async function passwordGrant(body: TokenGrantPasswordRequest, res: Express.Response): Promise<User> {
|
|
|
|
const { username, password } = body;
|
|
|
|
if (!body || !username || !password) {
|
|
|
|
throw new ApiError("Must specify username and password");
|
|
|
|
}
|
|
|
|
const user = await state.database.users.findByUsername(username);
|
|
|
|
if (!user) {
|
|
|
|
throw new ApiError("User does not exist");
|
|
|
|
}
|
|
|
|
const passwordMatches = await user.comparePassword(password);
|
|
|
|
if (passwordMatches) {
|
|
|
|
return user;
|
|
|
|
} else {
|
|
|
|
throw new ApiError("Invalid user credentials");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function refreshGrant(body: TokenGrantRefreshRequest, res: Express.Response): Promise<User> {
|
|
|
|
const { refresh_token } = body;
|
|
|
|
if (!body || !refresh_token) {
|
|
|
|
throw new ApiError("Must specify a refresh_token");
|
|
|
|
}
|
|
|
|
const claims = await verifyToken(refresh_token);
|
|
|
|
if (claims.type !== "refresh") {
|
|
|
|
throw new ApiError("Not a refresh token");
|
|
|
|
}
|
|
|
|
const user = await state.database.users.findOne(claims.aud);
|
|
|
|
if (!user) {
|
|
|
|
throw new ApiError("User no longer exists");
|
|
|
|
}
|
|
|
|
return user;
|
|
|
|
}
|
|
|
|
|
|
|
|
router.post("/token/grant", async (req, res) => {
|
|
|
|
const body: TokenGrantRequest = req.body;
|
|
|
|
let user: User;
|
|
|
|
if (body.grant_type === "password") {
|
|
|
|
user = await passwordGrant(body, res);
|
|
|
|
} else if (body.grant_type === "refresh") {
|
|
|
|
user = await refreshGrant(body, res);
|
|
|
|
} else {
|
|
|
|
throw new ApiError("Invalid grant_type");
|
|
|
|
}
|
|
|
|
const [access_token, refresh_token] = await Promise.all(
|
|
|
|
[await generateAccessToken(user, JWT_SECRET),
|
|
|
|
await generateRefreshToken(user, JWT_SECRET)]);
|
|
|
|
const response: TokenGrantResponse = {
|
|
|
|
access_token, refresh_token,
|
|
|
|
};
|
|
|
|
res.json(response);
|
|
|
|
});
|
|
|
|
|
|
|
|
router.post("/token/verify", authorizeAccess, async (req, res) => {
|
|
|
|
res.json({
|
|
|
|
ok: true,
|
|
|
|
token: req.token,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
return router;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function authorizeAccess(req: Express.Request, res: Express.Response) {
|
|
|
|
const bearer = req.headers.authorization;
|
|
|
|
if (!bearer) {
|
|
|
|
throw new ApiError("No Authorization header specified", ErrorCode.BadToken);
|
|
|
|
}
|
|
|
|
const matches = /^Bearer (.*)$/.exec(bearer);
|
|
|
|
if (!matches || !matches[1]) {
|
|
|
|
throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken);
|
|
|
|
}
|
|
|
|
const token = matches[1];
|
|
|
|
|
|
|
|
req.token = await verifyToken(token);
|
|
|
|
}
|