diff --git a/.example.env b/.example.env index 6e9c4d3b1..7c4945f82 100644 --- a/.example.env +++ b/.example.env @@ -58,6 +58,7 @@ DISALLOW_REGISTRATION=true # Optional - Disable anonymous link creation. Default is true. DISALLOW_ANONYMOUS_LINKS=true + # Optional - This would be shown to the user on the settings page # It's only for display purposes and has no other use SERVER_IP_ADDRESS= @@ -87,3 +88,15 @@ REPORT_EMAIL= # Optional - Support email to show on the app CONTACT_EMAIL= + +# Optional - Login with OIDC +OIDC_ENABLED=false +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_SCOPE= +OIDC_EMAIL_CLAIM= +OIDC_APP_URL= + +# Optional - Disable form-based login. Only makes sense when OIDC_ENABLED=true. +DISALLOW_FORM_LOGIN=false diff --git a/docker-compose.oidc.yml b/docker-compose.oidc.yml new file mode 100644 index 000000000..de64e739c --- /dev/null +++ b/docker-compose.oidc.yml @@ -0,0 +1,77 @@ +services: + server: + build: + context: . + volumes: + - db_data_sqlite:/var/lib/kutt + - custom:/kutt/custom + env_file: .env + environment: + DB_FILENAME: "/var/lib/kutt/data.sqlite" + DISALLOW_REGISTRATION: "false" + OIDC_ENABLED: "true" + OIDC_ISSUER: http://7f000101.nip.io:8080 + OIDC_CLIENT_ID: mock-client-id + OIDC_CLIENT_SECRET: some-client-Secret + OIDC_SCOPE: openid profile email + OIDC_APP_URL: http://localhost:3000 + ports: + - 3000:3000 + links: + - oidc-server-mock:7f000101.nip.io + oidc-server-mock: + container_name: oidc-server-mock + image: ghcr.io/soluto/oidc-server-mock:0.11.0 + ports: + - 8080:8080 + domainname: 7f000101.nip.io + environment: + SERVER_OPTIONS_INLINE: | + { + "AccessTokenJwtType": "JWT", + "Discovery": { + "ShowKeySet": true + }, + "Authentication": { + "CookieSameSiteMode": "Lax", + "CheckSessionCookieSameSiteMode": "Lax" + } + } + CLIENTS_CONFIGURATION_INLINE: | + [ + { + "ClientId": "mock-client-id", + "ClientSecrets": ["some-client-Secret"], + "Description": "Mock OIDC", + "AllowedGrantTypes": ["authorization_code"], + "AllowAccessTokensViaBrowser": true, + "RedirectUris": ["http://localhost:3000/*"], + "AllowedScopes": ["openid", "profile", "email"], + "IdentityTokenLifetime": 3600, + "AccessTokenLifetime": 3600 + } + ] + USERS_CONFIGURATION_INLINE: | + [ + { + "SubjectId":"1", + "Username":"user01", + "Password":"pwd", + "Claims": [ + { "Type": "name", "Value": "User 01", "ValueType": "string" }, + { "Type": "email", "Value": "user01@example.localhost", "ValueType": "string" } + ], + }, + { + "SubjectId":"2", + "Username":"user02", + "Password":"pwd", + "Claims": [ + { "Type": "name", "Value": "User 02", "ValueType": "string" }, + { "Type": "email", "Value": "user02@example.localhost", "ValueType": "string" } + ], + } + ] +volumes: + db_data_sqlite: + custom: diff --git a/package-lock.json b/package-lock.json index 00c1f0bed..97bb1e4b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,10 @@ "better-sqlite3": "11.8.1", "bull": "4.16.5", "cookie-parser": "1.4.7", + "cookie-session": "^2.1.0", "cors": "2.8.5", "date-fns": "2.30.0", - "dotenv": "^16.4.7", + "dotenv": "16.4.7", "envalid": "8.0.0", "express": "4.21.2", "express-rate-limit": "7.5.0", @@ -31,6 +32,7 @@ "mysql2": "3.12.0", "nanoid": "3.3.8", "nodemailer": "6.9.16", + "openid-client": "^5.7.0", "passport": "0.7.0", "passport-jwt": "4.0.1", "passport-local": "1.0.0", @@ -1654,12 +1656,49 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-session": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz", + "integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==", + "license": "MIT", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/core-js": { "version": "3.38.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", @@ -3689,6 +3728,14 @@ "node": ">=18" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -3813,6 +3860,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/knex": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", @@ -4588,6 +4647,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", @@ -4692,6 +4759,14 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4704,6 +4779,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4724,6 +4808,36 @@ "json-pointer": "0.6.2" } }, + "node_modules/openid-client": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -6393,6 +6507,15 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 84ffaa872..3b0aa53c0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "better-sqlite3": "11.8.1", "bull": "4.16.5", "cookie-parser": "1.4.7", + "cookie-session": "^2.1.0", "cors": "2.8.5", "date-fns": "2.30.0", "dotenv": "16.4.7", @@ -46,6 +47,7 @@ "mysql2": "3.12.0", "nanoid": "3.3.8", "nodemailer": "6.9.16", + "openid-client": "^5.7.0", "passport": "0.7.0", "passport-jwt": "4.0.1", "passport-local": "1.0.0", diff --git a/server/env.js b/server/env.js index 87f7bc373..1ed7d1f02 100644 --- a/server/env.js +++ b/server/env.js @@ -50,6 +50,7 @@ const spec = { REDIS_DB: num({ default: 0 }), DISALLOW_ANONYMOUS_LINKS: bool({ default: true }), DISALLOW_REGISTRATION: bool({ default: true }), + DISALLOW_FORM_LOGIN: bool({ default: false }), SERVER_IP_ADDRESS: str({ default: "" }), SERVER_CNAME_ADDRESS: str({ default: "" }), CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }), @@ -61,6 +62,13 @@ const spec = { MAIL_USER: str({ default: "" }), MAIL_FROM: str({ default: "", example: "Kutt " }), MAIL_PASSWORD: str({ default: "" }), + OIDC_ENABLED: bool({ default: false }), + OIDC_ISSUER: str({ default: "" }), + OIDC_CLIENT_ID: str({ default: "" }), + OIDC_CLIENT_SECRET: str({ default: "" }), + OIDC_SCOPE: str({ default: "openid profile email" }), + OIDC_EMAIL_CLAIM: str({ default: "email" }), + OIDC_APP_URL: str({ default: "" }), ENABLE_RATE_LIMIT: bool({ default: false }), REPORT_EMAIL: str({ default: "" }), CONTACT_EMAIL: str({ default: "" }), diff --git a/server/handlers/auth.handler.js b/server/handlers/auth.handler.js index cb120b5b4..f4d163ee6 100644 --- a/server/handlers/auth.handler.js +++ b/server/handlers/auth.handler.js @@ -16,9 +16,10 @@ const CustomError = utils.CustomError; function authenticate(type, error, isStrict, redirect) { return function auth(req, res, next) { if (req.user) return next(); - + passport.authenticate(type, (err, user, info) => { if (err) return next(err); + if (type === 'oidc' && info instanceof Error) return next(info); if ( req.isHTML && @@ -80,6 +81,7 @@ const jwtPage = authenticate("jwt", "Unauthorized.", true, "page"); const jwtLoose = authenticate("jwt", "Unauthorized.", false, "header"); const jwtLoosePage = authenticate("jwt", "Unauthorized.", false, "page"); const apikey = authenticate("localapikey", "API key is not correct.", false, null); +const oidc = authenticate("oidc", "Unauthorized", false, "page"); function admin(req, res, next) { if (req.user.admin) return next(); @@ -388,6 +390,7 @@ module.exports = { local, login, newPassword, + oidc, resetPassword, signup, verify, diff --git a/server/handlers/locals.handler.js b/server/handlers/locals.handler.js index 7b1b4ab25..2b28951bf 100644 --- a/server/handlers/locals.handler.js +++ b/server/handlers/locals.handler.js @@ -27,6 +27,8 @@ function config(req, res, next) { res.locals.server_ip_address = env.SERVER_IP_ADDRESS; res.locals.server_cname_address = env.SERVER_CNAME_ADDRESS; res.locals.disallow_registration = env.DISALLOW_REGISTRATION; + res.locals.disallow_form_login = env.DISALLOW_FORM_LOGIN; + res.locals.oidc_enabled = env.OIDC_ENABLED; res.locals.mail_enabled = env.MAIL_ENABLED; res.locals.report_email = env.REPORT_EMAIL; res.locals.custom_styles = utils.getCustomCSSFileNames(); diff --git a/server/passport.js b/server/passport.js index 9c4c8e96c..1c49e2a8b 100644 --- a/server/passport.js +++ b/server/passport.js @@ -3,6 +3,7 @@ const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt"); const { Strategy: LocalStrategy } = require("passport-local"); const passport = require("passport"); const bcrypt = require("bcryptjs"); +const crypto = require('crypto'); const query = require("./queries"); const env = require("./env"); @@ -69,3 +70,72 @@ passport.use( } }) ); + +async function enableOIDC() { + const requiredKeys = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_SCOPE", "OIDC_EMAIL_CLAIM", "OIDC_APP_URL"]; + requiredKeys.forEach((key) => { + if (!env[key]) { + throw new Error(`Missing required env ${key}`); + } + }); + const { Issuer, Strategy: OIDCStrategy, UserinfoResponse } = await import("openid-client"); + const issuer = await Issuer.discover(env.OIDC_ISSUER) + const client = new issuer.Client({ + client_id: env.OIDC_CLIENT_ID, + client_secret: env.OIDC_CLIENT_SECRET, + redirect_uris: [`${env.OIDC_APP_URL}/login/oidc`], + response_types: ["code"] + }); + + passport.use( + "oidc", + new OIDCStrategy( + { + client: client, + params: { + scope: env.OIDC_SCOPE, + prompt: "login" + }, + passReqToCallback: true + }, + async (req, tokenset, userinfo, done) => { + try { + const email = userinfo[env.OIDC_EMAIL_CLAIM]; + const existingUser = await query.user.find({ email }); + + // Existing user. + if (existingUser) return done(null, existingUser); + + // New user. + // Generate a random password which is not supposed to be used directly. + const salt = await bcrypt.genSalt(12); + const password = generateRandomPassword(); + const newUser = await query.user.add({ + email, + password, + }); + const updatedUsers = await query.user.update(newUser, { + verified: true, + verification_token: null, + verification_expires: null, + }); + return done(null, updatedUsers[0]); + + } catch (err) { + return done(err); + } + } + ) + ); + +} + +function generateRandomPassword() { + // 24-64 characters. + const length = Math.floor(Math.random()*41)+24; + return [...crypto.randomBytes(length)].map(byte => String.fromCharCode((byte % 93)+33)).join(''); +} + +if (env.OIDC_ENABLED) { + enableOIDC(); +} diff --git a/server/routes/renders.routes.js b/server/routes/renders.routes.js index 1c9ee2fae..ebdc98823 100644 --- a/server/routes/renders.routes.js +++ b/server/routes/renders.routes.js @@ -25,6 +25,12 @@ router.get( asyncHandler(renders.login) ); +router.get( + "/login/oidc", + asyncHandler(auth.oidc), + asyncHandler(auth.login) +); + router.get( "/logout", asyncHandler(renders.logout) diff --git a/server/server.js b/server/server.js index 606ed0e11..f14fb9fee 100644 --- a/server/server.js +++ b/server/server.js @@ -3,6 +3,7 @@ const env = require("./env"); const cookieParser = require("cookie-parser"); const passport = require("passport"); const express = require("express"); +const session = require("cookie-session"); const helmet = require("helmet"); const path = require("node:path"); const hbs = require("hbs"); @@ -39,6 +40,10 @@ app.use(helmet({ contentSecurityPolicy: false })); app.use(cookieParser()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use(session({ + keys: [env.JWT_SECRET], + maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days +})); // serve static app.use("/images", express.static("custom/images")); diff --git a/server/views/partials/auth/form.hbs b/server/views/partials/auth/form.hbs index baf0f90bf..03568cd31 100644 --- a/server/views/partials/auth/form.hbs +++ b/server/views/partials/auth/form.hbs @@ -1,4 +1,5 @@
+ {{#unless disallow_form_login}}