You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
174 lines
5.2 KiB
174 lines
5.2 KiB
import * as Express from "express"; |
|
import Router from "express-promise-router"; |
|
import * as jwt from "jsonwebtoken"; |
|
|
|
import TokenClaims from "@common/TokenClaims"; |
|
|
|
import { User } from "../models/User"; |
|
import { ServerState } from "../state"; |
|
import { ApiError } from "./errors"; |
|
import { |
|
TokenGrantPasswordRequest, |
|
TokenGrantRefreshRequest, |
|
TokenGrantRequest, |
|
TokenGrantResponse |
|
} from "@common/http"; |
|
|
|
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(401, "The specified token is expired", err)); |
|
} else if (err.name === "JsonWebTokenError") { |
|
reject(new ApiError(400, "Invalid token", 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(400, "Must specify username and password"); |
|
} |
|
const user = await User.loadByUsername(state.database, username); |
|
if (!user) { |
|
throw new ApiError(400, "User does not exist"); |
|
} |
|
const passwordMatches = await user.comparePassword(password); |
|
if (passwordMatches) { |
|
return user; |
|
} else { |
|
throw new ApiError(401, "Invalid user credentials"); |
|
} |
|
} |
|
|
|
async function refreshGrant(body: TokenGrantRefreshRequest, res: Express.Response): Promise<User> { |
|
const { refresh_token } = body; |
|
if (!body || !refresh_token) { |
|
throw new ApiError(400, "Must specify a refresh_token"); |
|
} |
|
const claims = await verifyToken(refresh_token); |
|
if (claims.type !== "refresh") { |
|
throw new ApiError(400, "Not a refresh token"); |
|
} |
|
const user = await User.load(state.database, claims.aud); |
|
if (!user) { |
|
throw new ApiError(400, "User does not exist"); |
|
} |
|
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(400, "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(401, "No bearer token specified"); |
|
} |
|
const matches = /^Bearer (.*)$/.exec(bearer); |
|
if (!matches || !matches[1]) { |
|
throw new ApiError(400, "Invalid bearer token specified"); |
|
} |
|
const token = matches[1]; |
|
|
|
req.token = await verifyToken(token); |
|
}
|
|
|