Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add header authentication for SSO #78

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 is 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)
- `HEADER_AUTH_WHITELISTED_IPS`: comma-separated list of IPs users can access Drift from using header authentication. Defaults to '127.0.0.1'.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this should meet my needs.

## Running with pm2

It's easy to start Drift using [pm2](https://pm2.keymetrics.io/).
Expand Down
23 changes: 23 additions & 0 deletions client/lib/hooks/use-signed-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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-signed-in", {
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)
Expand Down
20 changes: 19 additions & 1 deletion server/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ type Config = {
registration_password: string
welcome_content: string | undefined
welcome_title: string | undefined

// header auth
header_auth: boolean
header_auth_name: string | undefined
header_auth_role: string | undefined
header_auth_whitelisted_ips: string[] | undefined
}

type EnvironmentValue = string | undefined
Expand Down Expand Up @@ -55,6 +61,14 @@ export const config = (env: Environment): Config => {
}
}

const parseArrayFromString = (str: EnvironmentValue): string[] => {
if (str) {
return str.split(",").map((s) => s.trim())
} else {
return ['127.0.0.1']
}
}

const is_production = env.NODE_ENV === "production"

const developmentDefault = (
Expand All @@ -78,7 +92,11 @@ 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,
header_auth_whitelisted_ips: parseArrayFromString(env.HEADER_AUTH_WHITELISTED_IPS)
}
return config
}
Expand Down
2 changes: 1 addition & 1 deletion server/src/lib/middleware/__tests__/is-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserJwtRequest>
Expand Down
48 changes: 48 additions & 0 deletions server/src/lib/middleware/__tests__/is-signed-in.ts
Original file line number Diff line number Diff line change
@@ -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<UserJwtRequest>
let mockResponse: Partial<Response>
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)
// }
// })
})
48 changes: 0 additions & 48 deletions server/src/lib/middleware/__tests__/jwt.ts

This file was deleted.

2 changes: 1 addition & 1 deletion server/src/lib/middleware/__tests__/secret-key.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
91 changes: 91 additions & 0 deletions server/src/lib/middleware/is-signed-in.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { AuthToken } from "@lib/models/AuthToken"
import { NextFunction, Request, Response } from "express"
import * as jwt from "jsonwebtoken"
import config from "../config"
import { User as UserModel } from "../models/User"

export interface User {
id: string
}

export interface UserJwtRequest extends Request {
user?: User
}

export default async function isSignedIn(
req: UserJwtRequest,
res: Response,
next: NextFunction
) {
const authHeader = req.headers ? req.headers["authorization"] : undefined
const token = authHeader && authHeader.split(" ")[1]

if (config.header_auth && config.header_auth_name) {
if (!config.header_auth_whitelisted_ips?.includes(req.ip)) {
console.warn(`IP ${req.ip} is not whitelisted and tried to authenticate with header auth.`)
return res.sendStatus(401)
}

// 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()
console.log(`Created user ${username} with role ${role} via header auth.`)
}

req.user = user
next()
} else {
if (token == null) return res.sendStatus(401)

const authToken = await AuthToken.findOne({ where: { token: token } })
if (authToken == null) {
return res.sendStatus(401)
}

if (authToken.deletedAt) {
return res.sendStatus(401).json({
message: "Token is no longer valid"
})
}

jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
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"]
}
})
if (!userObj) {
return res.sendStatus(403)
}
req.user = user

next()
})
}
}
50 changes: 0 additions & 50 deletions server/src/lib/middleware/jwt.ts

This file was deleted.

2 changes: 1 addition & 1 deletion server/src/lib/middleware/secret-key.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import config from "@lib/config"
import { NextFunction, Request, Response } from "express"

export default function authenticateToken(
export default function secretKey(
req: Request,
res: Response,
next: NextFunction
Expand Down
6 changes: 3 additions & 3 deletions server/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -94,7 +94,7 @@ auth.post(
serverPassword: Joi.string().required().allow("", null)
}
}),
async (req, res, next) => {
async (req, res) => {
const error = "User does not exist or password is incorrect"
const errorToThrow = new Error(error)
try {
Expand Down Expand Up @@ -147,7 +147,7 @@ function generateAccessToken(user: User) {
return token
}

auth.get("/verify-token", jwt, async (req, res, next) => {
auth.get("/verify-signed-in", jwt, async (req, res, next) => {
try {
res.status(200).json({
message: "You are authenticated"
Expand Down
2 changes: 1 addition & 1 deletion server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading