mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-21 16:06:16 -04:00
Feat/oauth2 (#6281): Added oauth to API paths
* Added oauth provider. * Fixed provider. * Added auth flow. * Fixed auth flow and added scaffolding vite config. * Added working oauth2. * Fixed dockerfile. * Adapted run.sh script * Moved api tests to oauth2. * Updated security schemes. * Removed api key from existance. * Fixed installation * Added missing issuer in config. * Fixed dev dependencies. * Updated lock file.
This commit is contained in:
parent
562177022f
commit
fb56809e55
44 changed files with 1782 additions and 237 deletions
|
@ -21,30 +21,12 @@
|
|||
|
||||
import {MapArrayType} from "../types/MapType";
|
||||
|
||||
const absolutePaths = require('../utils/AbsolutePaths');
|
||||
import fs from 'fs';
|
||||
const api = require('../db/API');
|
||||
import log4js from 'log4js';
|
||||
const padManager = require('../db/PadManager');
|
||||
const randomString = require('../utils/randomstring');
|
||||
const argv = require('../utils/Cli').argv;
|
||||
import createHTTPError from 'http-errors';
|
||||
|
||||
const apiHandlerLogger = log4js.getLogger('APIHandler');
|
||||
|
||||
// ensure we have an apikey
|
||||
let apikey:string|null = null;
|
||||
const apikeyFilename = absolutePaths.makeAbsolute(argv.apikey || './APIKEY.txt');
|
||||
|
||||
try {
|
||||
apikey = fs.readFileSync(apikeyFilename, 'utf8');
|
||||
apiHandlerLogger.info(`Api key file read from: "${apikeyFilename}"`);
|
||||
} catch (e) {
|
||||
apiHandlerLogger.info(
|
||||
`Api key file "${apikeyFilename}" not found. Creating with random contents.`);
|
||||
apikey = randomString(32);
|
||||
fs.writeFileSync(apikeyFilename, apikey!, 'utf8');
|
||||
}
|
||||
import {Http2ServerRequest, Http2ServerResponse} from "node:http2";
|
||||
import {publicKeyExported} from "../security/OAuth2Provider";
|
||||
import {jwtVerify} from "jose";
|
||||
|
||||
// a list of all functions
|
||||
const version:MapArrayType<any> = {};
|
||||
|
@ -167,21 +149,20 @@ exports.version = version;
|
|||
|
||||
|
||||
type APIFields = {
|
||||
apikey: string;
|
||||
api_key: string;
|
||||
padID: string;
|
||||
padName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a HTTP API call
|
||||
* Handles an HTTP API call
|
||||
* @param {String} apiVersion the version of the api
|
||||
* @param {String} functionName the name of the called function
|
||||
* @param fields the params of the called function
|
||||
* @req express request object
|
||||
* @res express response object
|
||||
* @param req express request object
|
||||
* @param res express response object
|
||||
*/
|
||||
exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields) {
|
||||
exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields, req: Http2ServerRequest, res: Http2ServerResponse) {
|
||||
// say goodbye if this is an unknown API version
|
||||
if (!(apiVersion in version)) {
|
||||
throw new createHTTPError.NotFound('no such api version');
|
||||
|
@ -192,13 +173,20 @@ exports.handle = async function (apiVersion: string, functionName: string, field
|
|||
throw new createHTTPError.NotFound('no such function');
|
||||
}
|
||||
|
||||
// check the api key!
|
||||
fields.apikey = fields.apikey || fields.api_key;
|
||||
|
||||
if (fields.apikey !== apikey!.trim()) {
|
||||
if(!req.headers.authorization) {
|
||||
throw new createHTTPError.Unauthorized('no or wrong API Key');
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(req.headers.authorization!.replace("Bearer ", ""), publicKeyExported!, {algorithms: ['RS256'],
|
||||
requiredClaims: ["admin"]})
|
||||
|
||||
} catch (e) {
|
||||
throw new createHTTPError.Unauthorized('no or wrong API Key');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// sanitize any padIDs before continuing
|
||||
if (fields.padID) {
|
||||
fields.padID = await padManager.sanitizePadId(fields.padID);
|
||||
|
@ -217,7 +205,3 @@ exports.handle = async function (apiVersion: string, functionName: string, field
|
|||
// call the api function
|
||||
return api[functionName].apply(this, functionParams);
|
||||
};
|
||||
|
||||
exports.exportedForTestingOnly = {
|
||||
apiKey: apikey,
|
||||
};
|
||||
|
|
|
@ -483,14 +483,24 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
|
|||
...defaultResponses,
|
||||
},
|
||||
securitySchemes: {
|
||||
ApiKey: {
|
||||
type: 'apiKey',
|
||||
in: 'query',
|
||||
name: 'apikey',
|
||||
openid: {
|
||||
type: "oauth2",
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl: settings.sso.issuer+"/oidc/auth",
|
||||
tokenUrl: settings.sso.issuer+"/oidc/token",
|
||||
scopes: {
|
||||
openid: "openid",
|
||||
profile: "profile",
|
||||
email: "email",
|
||||
admin: "admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ApiKey: []}],
|
||||
security: [{openid: []}],
|
||||
};
|
||||
|
||||
// build operations
|
||||
|
@ -657,7 +667,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
|
|||
}
|
||||
|
||||
// start and bind to express
|
||||
api.init();
|
||||
await api.init();
|
||||
app.use(apiRoot, async (req:any, res:any) => {
|
||||
let response = null;
|
||||
try {
|
||||
|
|
274
src/node/security/OAuth2Provider.ts
Normal file
274
src/node/security/OAuth2Provider.ts
Normal file
|
@ -0,0 +1,274 @@
|
|||
import {ArgsExpressType} from "../types/ArgsExpressType";
|
||||
import Provider, {Account, Configuration} from 'oidc-provider';
|
||||
import {generateKeyPair, exportJWK, KeyLike} from 'jose'
|
||||
import MemoryAdapter from "./OIDCAdapter";
|
||||
import path from "path";
|
||||
const settings = require('../utils/Settings');
|
||||
import {IncomingForm} from 'formidable'
|
||||
import express, {Request, Response} from 'express';
|
||||
import {format} from 'url'
|
||||
import {ParsedUrlQuery} from "node:querystring";
|
||||
import {Http2ServerRequest, Http2ServerResponse} from "node:http2";
|
||||
|
||||
const configuration: Configuration = {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
findAccount: async (ctx, id) => {
|
||||
const users = settings.users as {
|
||||
[username: string]: {
|
||||
password: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const usersArray1 = Object.keys(users).map((username) => ({
|
||||
username,
|
||||
...users[username]
|
||||
}));
|
||||
|
||||
const account = usersArray1.find((user) => user.username === id);
|
||||
|
||||
if(account === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (account.is_admin) {
|
||||
return {
|
||||
accountId: id,
|
||||
claims: () => ({
|
||||
sub: id,
|
||||
admin: true
|
||||
})
|
||||
} as Account
|
||||
} else {
|
||||
return {
|
||||
accountId: id,
|
||||
claims: () => ({
|
||||
sub: id,
|
||||
})
|
||||
} as Account
|
||||
}
|
||||
},
|
||||
ttl:{
|
||||
AccessToken: 1 * 60 * 60, // 1 hour in seconds
|
||||
AuthorizationCode: 10 * 60, // 10 minutes in seconds
|
||||
ClientCredentials: 1 * 60 * 60, // 1 hour in seconds
|
||||
IdToken: 1 * 60 * 60, // 1 hour in seconds
|
||||
RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds
|
||||
},
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
email: ['email'],
|
||||
profile: ['name'],
|
||||
admin: ['admin']
|
||||
},
|
||||
cookies: {
|
||||
keys: ['oidc'],
|
||||
},
|
||||
features:{
|
||||
devInteractions: {enabled: false},
|
||||
},
|
||||
adapter: MemoryAdapter
|
||||
};
|
||||
|
||||
|
||||
export let publicKeyExported: KeyLike|null
|
||||
export let privateKeyExported: KeyLike|null
|
||||
|
||||
/*
|
||||
This function is used to initialize the OAuth2 provider
|
||||
*/
|
||||
export const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => {
|
||||
const {privateKey, publicKey} = await generateKeyPair('RS256');
|
||||
const privateKeyJWK = await exportJWK(privateKey);
|
||||
publicKeyExported = publicKey
|
||||
privateKeyExported = privateKey
|
||||
|
||||
const oidc = new Provider(settings.sso.issuer, {
|
||||
...configuration, jwks: {
|
||||
keys: [
|
||||
privateKeyJWK
|
||||
],
|
||||
},
|
||||
conformIdTokenClaims: false,
|
||||
claims: {
|
||||
address: ['address'],
|
||||
email: ['email', 'email_verified'],
|
||||
phone: ['phone_number', 'phone_number_verified'],
|
||||
profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name',
|
||||
'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'],
|
||||
},
|
||||
features:{
|
||||
userinfo: {enabled: true},
|
||||
claimsParameter: {enabled: true},
|
||||
devInteractions: {enabled: false},
|
||||
resourceIndicators: {enabled: true, defaultResource(ctx) {
|
||||
return ctx.origin;
|
||||
},
|
||||
getResourceServerInfo(ctx, resourceIndicator, client) {
|
||||
return {
|
||||
scope: client.scope as string,
|
||||
audience: 'account',
|
||||
accessTokenFormat: 'jwt',
|
||||
};
|
||||
},
|
||||
useGrantedResource(ctx, model) {
|
||||
return true;
|
||||
},},
|
||||
jwtResponseModes: {enabled: true},
|
||||
},
|
||||
clientBasedCORS: (ctx, origin, client) => {
|
||||
return true
|
||||
},
|
||||
extraTokenClaims: async (ctx, token) => {
|
||||
|
||||
|
||||
if(token.kind === 'AccessToken') {
|
||||
// Add your custom claims here. For example:
|
||||
const users = settings.users as {
|
||||
[username: string]: {
|
||||
password: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const usersArray1 = Object.keys(users).map((username) => ({
|
||||
username,
|
||||
...users[username]
|
||||
}));
|
||||
|
||||
const account = usersArray1.find((user) => user.username === token.accountId);
|
||||
return {
|
||||
admin: account?.is_admin
|
||||
};
|
||||
}
|
||||
},
|
||||
clients: settings.sso.clients
|
||||
});
|
||||
|
||||
|
||||
args.app.post('/interaction/:uid', async (req: Http2ServerRequest, res: Http2ServerResponse, next:Function) => {
|
||||
const formid = new IncomingForm();
|
||||
try {
|
||||
// @ts-ignore
|
||||
const {login, password} = (await formid.parse(req))[0]
|
||||
const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res);
|
||||
|
||||
const client = await oidc.Client.find(params.client_id as string);
|
||||
|
||||
switch (prompt.name) {
|
||||
case 'login': {
|
||||
const users = settings.users as {
|
||||
[username: string]: {
|
||||
password: string;
|
||||
admin: boolean;
|
||||
}
|
||||
}
|
||||
const usersArray1 = Object.keys(users).map((username) => ({
|
||||
username,
|
||||
...users[username]
|
||||
}));
|
||||
const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string);
|
||||
if (!account) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({error: "Invalid login"}));
|
||||
}
|
||||
|
||||
if (account) {
|
||||
await oidc.interactionFinished(req, res, {
|
||||
login: {accountId: account.username}
|
||||
}, {mergeWithLastSubmission: false});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'consent': {
|
||||
let grant;
|
||||
if (grantId) {
|
||||
// we'll be modifying existing grant in existing session
|
||||
grant = await oidc.Grant.find(grantId);
|
||||
} else {
|
||||
// we're establishing a new grant
|
||||
grant = new oidc.Grant({
|
||||
accountId: session!.accountId,
|
||||
clientId: params.client_id as string,
|
||||
});
|
||||
}
|
||||
|
||||
if (prompt.details.missingOIDCScope) {
|
||||
// @ts-ignore
|
||||
grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' '));
|
||||
}
|
||||
if (prompt.details.missingOIDCClaims) {
|
||||
grant!.addOIDCClaims(prompt.details.missingOIDCClaims as string[]);
|
||||
}
|
||||
if (prompt.details.missingResourceScopes) {
|
||||
for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) {
|
||||
grant!.addResourceScope(indicator, scope.join(' '));
|
||||
}
|
||||
}
|
||||
const result = {consent: {grantId: await grant!.save()}};
|
||||
await oidc.interactionFinished(req, res, result, {
|
||||
mergeWithLastSubmission: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
await next();
|
||||
} catch (err:any) {
|
||||
return res.writeHead(500).end(err.message);
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
args.app.get('/interaction/:uid', async (req: Request, res: Response, next: Function) => {
|
||||
try {
|
||||
const {
|
||||
uid, prompt, params, session,
|
||||
} = await oidc.interactionDetails(req, res);
|
||||
|
||||
params["state"] = uid
|
||||
|
||||
switch (prompt.name) {
|
||||
case 'login': {
|
||||
res.redirect(format({
|
||||
pathname: '/views/login.html',
|
||||
query: params as ParsedUrlQuery
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'consent': {
|
||||
res.redirect(format({
|
||||
pathname: '/views/consent.html',
|
||||
query: params as ParsedUrlQuery
|
||||
}))
|
||||
break
|
||||
}
|
||||
default:
|
||||
return res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html'));
|
||||
}
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24}));
|
||||
|
||||
/*
|
||||
oidc.on('authorization.error', (ctx, error) => {
|
||||
console.log('authorization.error', error);
|
||||
})
|
||||
|
||||
oidc.on('server_error', (ctx, error) => {
|
||||
console.log('server_error', error);
|
||||
})
|
||||
oidc.on('grant.error', (ctx, error) => {
|
||||
console.log('grant.error', error);
|
||||
})
|
||||
oidc.on('introspection.error', (ctx, error) => {
|
||||
console.log('introspection.error', error);
|
||||
})
|
||||
oidc.on('revocation.error', (ctx, error) => {
|
||||
console.log('revocation.error', error);
|
||||
})*/
|
||||
args.app.use("/oidc", oidc.callback());
|
||||
//cb();
|
||||
}
|
5
src/node/security/OAuth2User.ts
Normal file
5
src/node/security/OAuth2User.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type OAuth2User = {
|
||||
username: string;
|
||||
password: string;
|
||||
admin: boolean;
|
||||
}
|
115
src/node/security/OIDCAdapter.ts
Normal file
115
src/node/security/OIDCAdapter.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import {LRUCache} from 'lru-cache';
|
||||
import type {Adapter, AdapterPayload} from "oidc-provider";
|
||||
|
||||
|
||||
const options = {
|
||||
max: 500,
|
||||
sizeCalculation: (item:any, key:any) => {
|
||||
return 1
|
||||
},
|
||||
// for use with tracking overall storage size
|
||||
maxSize: 5000,
|
||||
|
||||
// how long to live in ms
|
||||
ttl: 1000 * 60 * 5,
|
||||
|
||||
// return stale items before removing from cache?
|
||||
allowStale: false,
|
||||
|
||||
updateAgeOnGet: false,
|
||||
updateAgeOnHas: false,
|
||||
}
|
||||
|
||||
const epochTime = (date = Date.now()) => Math.floor(date / 1000);
|
||||
|
||||
const storage = new LRUCache<string,AdapterPayload|string[]|string>(options);
|
||||
|
||||
function grantKeyFor(id: string) {
|
||||
return `grant:${id}`;
|
||||
}
|
||||
|
||||
function userCodeKeyFor(userCode:string) {
|
||||
return `userCode:${userCode}`;
|
||||
}
|
||||
|
||||
class MemoryAdapter implements Adapter{
|
||||
private readonly name: string;
|
||||
constructor(name:string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
key(id:string) {
|
||||
return `${this.name}:${id}`;
|
||||
}
|
||||
|
||||
destroy(id:string) {
|
||||
const key = this.key(id);
|
||||
|
||||
const found = storage.get(key) as AdapterPayload;
|
||||
const grantId = found && found.grantId;
|
||||
|
||||
storage.delete(key);
|
||||
|
||||
if (grantId) {
|
||||
const grantKey = grantKeyFor(grantId);
|
||||
(storage.get(grantKey) as string[])!.forEach(token => storage.delete(token));
|
||||
storage.delete(grantKey);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
consume(id: string) {
|
||||
(storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
find(id: string): Promise<AdapterPayload | void | undefined> {
|
||||
if (storage.has(this.key(id))){
|
||||
return Promise.resolve<AdapterPayload>(storage.get(this.key(id)) as AdapterPayload);
|
||||
}
|
||||
return Promise.resolve<undefined>(undefined)
|
||||
}
|
||||
|
||||
findByUserCode(userCode: string) {
|
||||
const id = storage.get(userCodeKeyFor(userCode)) as string;
|
||||
return this.find(id);
|
||||
}
|
||||
|
||||
upsert(id: string, payload: {
|
||||
iat: number;
|
||||
exp: number;
|
||||
uid: string;
|
||||
kind: string;
|
||||
jti: string;
|
||||
accountId: string;
|
||||
loginTs: number;
|
||||
}, expiresIn: number) {
|
||||
const key = this.key(id);
|
||||
|
||||
storage.set(key, payload, {ttl: expiresIn * 1000});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
findByUid(uid: string): Promise<AdapterPayload | void | undefined> {
|
||||
for(const [_, value] of storage.entries()){
|
||||
if(typeof value ==="object" && "uid" in value && value.uid === uid){
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
revokeByGrantId(grantId: string): Promise<void | undefined> {
|
||||
const grantKey = grantKeyFor(grantId);
|
||||
const grant = storage.get(grantKey) as string[];
|
||||
if (grant) {
|
||||
grant.forEach((token) => storage.delete(token));
|
||||
storage.delete(grantKey);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoryAdapter
|
|
@ -45,10 +45,5 @@ for (let i = 0; i < argv.length; i++) {
|
|||
exports.argv.sessionkey = arg;
|
||||
}
|
||||
|
||||
// Override location of APIKEY.txt file
|
||||
if (prevArg === '--apikey') {
|
||||
exports.argv.apikey = arg;
|
||||
}
|
||||
|
||||
prevArg = arg;
|
||||
}
|
||||
|
|
|
@ -342,6 +342,11 @@ exports.requireAuthentication = false;
|
|||
exports.requireAuthorization = false;
|
||||
exports.users = {};
|
||||
|
||||
/*
|
||||
* This setting is used for configuring sso
|
||||
*/
|
||||
exports.sso = {}
|
||||
|
||||
/*
|
||||
* Show settings in admin page, by default it is true
|
||||
*/
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue