diff --git a/README.md b/README.md index 77bceb5e..b088fc7f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ You can change these to your liking. - `ENABLE_ADMIN`: the first account created is an administrator account - `DRIFT_HOME`: defaults to ~/.drift, the directory for storing the database and eventually images +### For SSO + +- `HEADER_AUTH`: if true, enables authenthication via the HTTP header specified in `HEADER_AUTH_KEY` which generally populated at the reverse-proxy level. +- `HEADER_AUTH_KEY`: if `HEADER_AUTH` is true, the header to look for the users username (like `Auth-User`) +- `HEADER_AUTH_ROLE`: if `HEADER_AUTH` is true, the header to look for the users role ("user" | "admin", at the moment) + ## Running with pm2 It's easy to start Drift using [pm2](https://pm2.keymetrics.io/). diff --git a/client/lib/hooks/use-signed-in.ts b/client/lib/hooks/use-signed-in.ts index 60aacef0..428f0f1d 100644 --- a/client/lib/hooks/use-signed-in.ts +++ b/client/lib/hooks/use-signed-in.ts @@ -2,6 +2,7 @@ import Cookies from "js-cookie" import { useEffect } from "react" import useSharedState from "./use-shared-state" + const useSignedIn = () => { const [signedIn, setSignedIn] = useSharedState( "signedIn", @@ -14,6 +15,28 @@ const useSignedIn = () => { Cookies.set("drift-token", token) } + useEffect(() => { + const attemptSignIn = async () => { + // If header auth is enabled, the reverse proxy will add it between this fetch and the server. + // Otherwise, the token will be used. + const res = await fetch("/server-api/auth/verify-token", { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + } + }) + + if (res.status !== 200) { + setSignedIn(false) + return + } + } + + attemptSignIn() + }, [setSignedIn, token]) + + useEffect(() => { if (token) { setSignedIn(true) diff --git a/server/src/lib/config.ts b/server/src/lib/config.ts index e4015357..46b6241d 100644 --- a/server/src/lib/config.ts +++ b/server/src/lib/config.ts @@ -9,6 +9,9 @@ type Config = { registration_password: string welcome_content: string | undefined welcome_title: string | undefined + header_auth: boolean + header_auth_name: string | undefined + header_auth_role: string | undefined } type EnvironmentValue = string | undefined @@ -78,7 +81,10 @@ export const config = (env: Environment): Config => { secret_key: developmentDefault(env.SECRET_KEY, "SECRET_KEY", "secret"), registration_password: env.REGISTRATION_PASSWORD ?? "", welcome_content: env.WELCOME_CONTENT, - welcome_title: env.WELCOME_TITLE + welcome_title: env.WELCOME_TITLE, + header_auth: stringToBoolean(env.HEADER_AUTH), + header_auth_name: env.HEADER_AUTH_NAME, + header_auth_role: env.HEADER_AUTH_ROLE } return config } diff --git a/server/src/lib/middleware/__tests__/is-admin.ts b/server/src/lib/middleware/__tests__/is-admin.ts index ae585132..8aac8b1f 100644 --- a/server/src/lib/middleware/__tests__/is-admin.ts +++ b/server/src/lib/middleware/__tests__/is-admin.ts @@ -2,7 +2,7 @@ // import { app } from '../../../app' import { NextFunction, Response } from "express" import isAdmin from "@lib/middleware/is-admin" -import { UserJwtRequest } from "@lib/middleware/jwt" +import { UserJwtRequest } from "@lib/middleware/is-signed-in" describe("is-admin middlware", () => { let mockRequest: Partial diff --git a/server/src/lib/middleware/__tests__/is-signed-in.ts b/server/src/lib/middleware/__tests__/is-signed-in.ts new file mode 100644 index 00000000..bf359b25 --- /dev/null +++ b/server/src/lib/middleware/__tests__/is-signed-in.ts @@ -0,0 +1,48 @@ +import jwt, { UserJwtRequest } from "@lib/middleware/is-signed-in" +import { NextFunction, Response } from "express" + +describe("jwt is-signed-in middlware", () => { + let mockRequest: Partial + let mockResponse: Partial + let nextFunction: NextFunction = jest.fn() + + beforeEach(() => { + mockRequest = {} + mockResponse = { + sendStatus: jest.fn().mockReturnThis() + } + }) + + it("should return 401 if no authorization header", () => { + const res = mockResponse as Response + jwt(mockRequest as UserJwtRequest, res, nextFunction) + expect(res.sendStatus).toHaveBeenCalledWith(401) + }) + + it("should return 401 if no token is supplied", () => { + const req = mockRequest as UserJwtRequest + req.headers = { + authorization: "Bearer" + } + jwt(req, mockResponse as Response, nextFunction) + expect(mockResponse.sendStatus).toBeCalledWith(401) + }) + + // it("should return 401 if token is deleted", async () => { + // try { + // const tokenString = "123" + + // const req = mockRequest as UserJwtRequest + // req.headers = { + // authorization: `Bearer ${tokenString}` + // } + // jwt(req, mockResponse as Response, nextFunction) + // expect(mockResponse.sendStatus).toBeCalledWith(401) + // expect(mockResponse.json).toBeCalledWith({ + // message: "Token is no longer valid" + // }) + // } catch (e) { + // console.log(e) + // } + // }) +}) diff --git a/server/src/lib/middleware/__tests__/jwt.ts b/server/src/lib/middleware/__tests__/jwt.ts deleted file mode 100644 index 4673c13e..00000000 --- a/server/src/lib/middleware/__tests__/jwt.ts +++ /dev/null @@ -1,48 +0,0 @@ -import jwt, { UserJwtRequest } from "@lib/middleware/jwt" -import { NextFunction, Response } from "express" - -describe("jwt middlware", () => { - let mockRequest: Partial - let mockResponse: Partial - let nextFunction: NextFunction = jest.fn() - - beforeEach(() => { - mockRequest = {} - mockResponse = { - sendStatus: jest.fn().mockReturnThis() - } - }) - - it("should return 401 if no authorization header", () => { - const res = mockResponse as Response - jwt(mockRequest as UserJwtRequest, res, nextFunction) - expect(res.sendStatus).toHaveBeenCalledWith(401) - }) - - it("should return 401 if no token is supplied", () => { - const req = mockRequest as UserJwtRequest - req.headers = { - authorization: "Bearer" - } - jwt(req, mockResponse as Response, nextFunction) - expect(mockResponse.sendStatus).toBeCalledWith(401) - }) - - // it("should return 401 if token is deleted", async () => { - // try { - // const tokenString = "123" - - // const req = mockRequest as UserJwtRequest - // req.headers = { - // authorization: `Bearer ${tokenString}` - // } - // jwt(req, mockResponse as Response, nextFunction) - // expect(mockResponse.sendStatus).toBeCalledWith(401) - // expect(mockResponse.json).toBeCalledWith({ - // message: "Token is no longer valid" - // }) - // } catch (e) { - // console.log(e) - // } - // }) -}) diff --git a/server/src/lib/middleware/__tests__/secret-key.ts b/server/src/lib/middleware/__tests__/secret-key.ts index 39a7381c..6ea226fb 100644 --- a/server/src/lib/middleware/__tests__/secret-key.ts +++ b/server/src/lib/middleware/__tests__/secret-key.ts @@ -1,7 +1,7 @@ // import * as request from 'supertest' // import { app } from '../../../app' import { NextFunction, Response } from "express" -import { UserJwtRequest } from "@lib/middleware/jwt" +import { UserJwtRequest } from "@lib/middleware/is-signed-in" import secretKey from "@lib/middleware/secret-key" import config from "@lib/config" diff --git a/server/src/lib/middleware/jwt.ts b/server/src/lib/middleware/is-signed-in.ts similarity index 50% rename from server/src/lib/middleware/jwt.ts rename to server/src/lib/middleware/is-signed-in.ts index 189aa10b..48e2082d 100644 --- a/server/src/lib/middleware/jwt.ts +++ b/server/src/lib/middleware/is-signed-in.ts @@ -20,6 +20,33 @@ export default async function authenticateToken( const authHeader = req.headers ? req.headers["authorization"] : undefined const token = authHeader && authHeader.split(" ")[1] + if (config.header_auth && config.header_auth_name) { + // with header auth, we assume the user is authenticated, + // but their user may not be created in the database yet. + + let user = await UserModel.findByPk(req.user?.id) + if (!user) { + const username = req.header[config.header_auth_name] + const role = config.header_auth_role ? req.header[config.header_auth_role] || "user" : "user" + user = new UserModel({ + username, + role + }) + await user.save() + } + + if (!token) { + const token = jwt.sign({ id: user.id }, config.jwt_secret, { + expiresIn: "2d" + }) + const authToken = new AuthToken({ + userId: user.id, + token: token + }) + await authToken.save() + } + } + if (token == null) return res.sendStatus(401) const authToken = await AuthToken.findOne({ where: { token: token } }) @@ -34,7 +61,23 @@ export default async function authenticateToken( } jwt.verify(token, config.jwt_secret, async (err: any, user: any) => { - if (err) return res.sendStatus(403) + if (err) { + if (config.header_auth) { + // if the token has expired or is invalid, we need to delete it and generate a new one + authToken.destroy() + const token = jwt.sign({ id: user.id }, config.jwt_secret, { + expiresIn: "2d" + }) + const newToken = new AuthToken({ + userId: user.id, + token: token + }) + await newToken.save() + } else { + return res.sendStatus(403) + } + } + const userObj = await UserModel.findByPk(user.id, { attributes: { exclude: ["password"] diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index ca763598..15bd01ec 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -4,7 +4,7 @@ import { User } from "@lib/models/User" import { AuthToken } from "@lib/models/AuthToken" import { sign, verify } from "jsonwebtoken" import config from "@lib/config" -import jwt, { UserJwtRequest } from "@lib/middleware/jwt" +import jwt, { UserJwtRequest } from "@lib/middleware/is-signed-in" import { celebrate, Joi } from "celebrate" import secretKey from "@lib/middleware/secret-key" @@ -94,7 +94,11 @@ auth.post( serverPassword: Joi.string().required().allow("", null) } }), - async (req, res, next) => { + async (req, res) => { + if (config.header_auth) { + + } + const error = "User does not exist or password is incorrect" const errorToThrow = new Error(error) try { diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index a4c05c61..14940b14 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -2,7 +2,7 @@ import { celebrate, Joi } from "celebrate" import { Router } from "express" import { File } from "@lib/models/File" import secretKey from "@lib/middleware/secret-key" -import jwt from "@lib/middleware/jwt" +import jwt from "@lib/middleware/is-signed-in" import getHtmlFromFile from "@lib/get-html-from-drift-file" export const files = Router() diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 6f95ef69..7cbcae8b 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -2,7 +2,7 @@ import { Router } from "express" import { celebrate, Joi } from "celebrate" import { File } from "@lib/models/File" import { Post } from "@lib/models/Post" -import jwt, { UserJwtRequest } from "@lib/middleware/jwt" +import jwt, { UserJwtRequest } from "@lib/middleware/is-signed-in" import * as crypto from "crypto" import { User } from "@lib/models/User" import secretKey from "@lib/middleware/secret-key" diff --git a/server/src/routes/user.ts b/server/src/routes/user.ts index 46368d40..baa0b947 100644 --- a/server/src/routes/user.ts +++ b/server/src/routes/user.ts @@ -1,5 +1,5 @@ import { Router } from "express" -import jwt, { UserJwtRequest } from "@lib/middleware/jwt" +import jwt, { UserJwtRequest } from "@lib/middleware/is-signed-in" import { User } from "@lib/models/User" import { celebrate, Joi } from "celebrate" diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts new file mode 100644 index 00000000..a2737437 --- /dev/null +++ b/server/src/routes/users.ts @@ -0,0 +1,31 @@ +import { Router } from "express" +import jwt, { UserJwtRequest } from "@lib/middleware/is-signed-in" +import { User } from "@lib/models/User" + +export const users = Router() + +users.get("/self", jwt, async (req: UserJwtRequest, res, next) => { + const error = () => + res.status(401).json({ + message: "Unauthorized" + }) + + try { + if (!req.user) { + return error() + } + + const user = await User.findByPk(req.user?.id, { + attributes: { + exclude: ["password"] + } + }) + if (!user) { + return error() + } + + res.json(user) + } catch (error) { + next(error) + } +})