diff --git a/packages/backend/package.json b/packages/backend/package.json index f2f12392..80452a10 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -34,6 +34,7 @@ "form-data": "^4.0.0", "handlebars": "^4.7.8", "helmet": "^7.0.0", + "hi-base32": "^0.5.1", "html-entities": "^2.3.3", "is-glob": "^4.0.3", "isbot": "^3.7.1", @@ -53,6 +54,7 @@ "nodemailer": "^6.9.3", "on-finished": "^2.4.1", "openai": "^4.20.1", + "otpauth": "^9.2.3", "prompt-sync": "^4.2.0", "recursive-readdir": "^2.2.3", "response-time": "^2.3.2", diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index 04718d63..4d1f84e5 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -201,6 +201,9 @@ const install = async ({ services, app }) => { const { TokenService } = require('./services/auth/TokenService'); services.registerService('token', TokenService); + + const { OTPService } = require('./services/auth/OTPService'); + services.registerService('otp', OTPService); } const install_legacy = async ({ services }) => { diff --git a/packages/backend/src/api/APIError.js b/packages/backend/src/api/APIError.js index 92092236..b3fce16d 100644 --- a/packages/backend/src/api/APIError.js +++ b/packages/backend/src/api/APIError.js @@ -331,6 +331,10 @@ module.exports = class APIError { status: 403, message: 'Attempted to create an access token with no permissions.', }, + 'invalid_action': { + status: 400, + message: ({ action }) => `Invalid action: ${quot(action)}.`, + }, // Object Mapping 'field_not_allowed_for_create': { diff --git a/packages/backend/src/routers/auth/configure-2fa.js b/packages/backend/src/routers/auth/configure-2fa.js new file mode 100644 index 00000000..a9aef1d0 --- /dev/null +++ b/packages/backend/src/routers/auth/configure-2fa.js @@ -0,0 +1,52 @@ +const APIError = require("../../api/APIError"); +const eggspress = require("../../api/eggspress"); +const { UserActorType } = require("../../services/auth/Actor"); +const { DB_WRITE } = require("../../services/database/consts"); +const { Context } = require("../../util/context"); + +module.exports = eggspress('/auth/configure-2fa/:action', { + subdomain: 'api', + auth2: true, + allowedMethods: ['POST'], +}, async (req, res, next) => { + const action = req.params.action; + const x = Context.get(); + + // Only users can configure 2FA + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + const user = actor.type.user; + + const actions = {}; + + const db = await x.get('services').get('database').get(DB_WRITE, '2fa'); + + actions.enable = async () => { + const svc_otp = x.get('services').get('otp'); + const result = svc_otp.create_secret(); + await db.write( + `UPDATE user SET otp_secret = ? WHERE uuid = ?`, + [result.secret, user.uuid] + ); + return result; + }; + + actions.disable = async () => { + await db.write( + `UPDATE user SET otp_secret = NULL WHERE uuid = ?`, + [user.uuid] + ); + return { success: true }; + }; + + if ( ! actions[action] ) { + throw APIError.create('invalid_action', null, { action }); + } + + const result = await actions[action](); + + res.json(result); +}); diff --git a/packages/backend/src/routers/login.js b/packages/backend/src/routers/login.js index ea638cb2..d54bd075 100644 --- a/packages/backend/src/routers/login.js +++ b/packages/backend/src/routers/login.js @@ -22,6 +22,35 @@ const router = new express.Router(); const { get_user, body_parser_error_handler } = require('../helpers'); const config = require('../config'); + +const complete_ = async ({ req, res, user }) => { + const svc_auth = req.services.get('auth'); + const { token } = await svc_auth.create_session_token(user, { req }); + + //set cookie + // res.cookie(config.cookie_name, token); + res.cookie(config.cookie_name, token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + + // send response + console.log('200 response?'); + return res.send({ + proceed: true, + next_step: 'complete', + token: token, + user:{ + username: user.username, + uuid: user.uuid, + email: user.email, + email_confirmed: user.email_confirmed, + is_temp: (user.password === null && user.email === null), + } + }) +}; + // -----------------------------------------------------------------------// // POST /file // -----------------------------------------------------------------------// @@ -32,7 +61,6 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res // modules const bcrypt = require('bcrypt') - const jwt = require('jsonwebtoken') const validator = require('validator') // either username or email must be provided @@ -88,34 +116,82 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res return res.status(400).send('Incorrect password.') // check password if(await bcrypt.compare(req.body.password, user.password)){ - const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); - //set cookie - // res.cookie(config.cookie_name, token); - res.cookie(config.cookie_name, token, { - sameSite: 'none', - secure: true, - httpOnly: true, - }); + // We create a JWT that can ONLY be used on the endpoint that + // accepts the OTP code. + if ( user.otp_secret ) { + const svc_token = req.services.get('token'); + const otp_jwt_token = svc_token.sign('otp', { + user_uid: user.uuid, + }, { expiresIn: '5m' }); - // send response - return res.send({ - token: token, - user:{ - username: user.username, - uuid: user.uuid, - email: user.email, - email_confirmed: user.email_confirmed, - is_temp: (user.password === null && user.email === null), - } - }) + return res.status(202).send({ + proceed: true, + next_step: 'otp', + otp_jwt_token: otp_jwt_token, + }); + } + + console.log('UMM?'); + return await complete_({ req, res, user }); }else{ return res.status(400).send('Incorrect password.') } }catch(e){ + console.error(e); return res.status(400).send(e); } }) -module.exports = router \ No newline at end of file +router.post('/login/otp', express.json(), body_parser_error_handler, async (req, res, next) => { + // either api. subdomain or no subdomain + if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '') + next(); + + if ( ! req.body.token ) { + return res.status(400).send('token is required.'); + } + + if ( ! req.body.code ) { + return res.status(400).send('code is required.'); + } + + const svc_token = req.services.get('token'); + let decoded; try { + decoded = svc_token.verify('otp', req.body.token); + } catch ( e ) { + return res.status(400).send('Invalid token.'); + } + + if ( ! decoded.user_uid ) { + return res.status(400).send('Invalid token.'); + } + + const user = await get_user({ uuid: decoded.user_uid, cached: false }); + if ( ! user ) { + return res.status(400).send('User not found.'); + } + + const svc_otp = req.services.get('otp'); + if ( ! svc_otp.verify(user.otp_secret, req.body.code) ) { + + // THIS MAY BE COUNTER-INTUITIVE + // + // A successfully handled request, with the correct format, + // but incorrect credentials when NOT using the HTTP + // authentication framework provided by RFC 7235, SHOULD + // return status 200. + // + // Source: I asked Julian Reschke in an email, and then he + // contributed to this discussion: + // https://stackoverflow.com/questions/32752578 + + return res.status(200).send({ + proceed: false, + }); + } + + return await complete_({ req, res, user }); +}); + +module.exports = router; diff --git a/packages/backend/src/services/PuterAPIService.js b/packages/backend/src/services/PuterAPIService.js index 750ed6c0..f8191a32 100644 --- a/packages/backend/src/services/PuterAPIService.js +++ b/packages/backend/src/services/PuterAPIService.js @@ -38,6 +38,7 @@ class PuterAPIService extends BaseService { app.use(require('../routers/auth/app-uid-from-origin')) app.use(require('../routers/auth/create-access-token')) app.use(require('../routers/auth/delete-own-user')) + app.use(require('../routers/auth/configure-2fa')) app.use(require('../routers/drivers/call')) app.use(require('../routers/drivers/list-interfaces')) app.use(require('../routers/drivers/usage')) diff --git a/packages/backend/src/services/auth/OTPService.js b/packages/backend/src/services/auth/OTPService.js new file mode 100644 index 00000000..7c61e5ee --- /dev/null +++ b/packages/backend/src/services/auth/OTPService.js @@ -0,0 +1,56 @@ +const BaseService = require("../BaseService"); + +class OTPService extends BaseService { + static MODULES = { + otpauth: require('otpauth'), + crypto: require('crypto'), + ['hi-base32']: require('hi-base32'), + } + + create_secret () { + const require = this.require; + const otpauth = require('otpauth'); + + const secret = this.gen_otp_secret_(); + const totp = new otpauth.TOTP({ + issuer: 'puter.com', + label: 'Puter Auth', + algorithm: 'SHA1', + digits: 6, + secret, + }); + + return { + url: totp.toString(), + secret, + }; + } + + verify (secret, code) { + const require = this.require; + const otpauth = require('otpauth'); + + const totp = new otpauth.TOTP({ + issuer: 'puter.com', + label: 'Puter Auth', + algorithm: 'SHA1', + digits: 6, + secret, + }); + + const ok = totp.validate({ token: code }); + return ok; + } + + gen_otp_secret_ () { + const require = this.require; + const crypto = require('crypto'); + const { encode } = require('hi-base32'); + + const buffer = crypto.randomBytes(15); + const base32 = encode(buffer).replace(/=/g, "").substring(0, 24); + return base32; + }; +}; + +module.exports = { OTPService }; diff --git a/packages/backend/src/services/auth/TokenService.js b/packages/backend/src/services/auth/TokenService.js index f3febad1..e20b7fde 100644 --- a/packages/backend/src/services/auth/TokenService.js +++ b/packages/backend/src/services/auth/TokenService.js @@ -127,6 +127,8 @@ class TokenService extends BaseService { } _compress_payload (context, payload) { + if ( ! context ) return payload; + const fullkey_to_info = context.fullkey_to_info; const compressed = {}; @@ -154,6 +156,8 @@ class TokenService extends BaseService { } _decompress_payload (context, payload) { + if ( ! context ) return payload; + const fullkey_to_info = context.fullkey_to_info; const short_to_fullkey = context.short_to_fullkey; diff --git a/packages/backend/src/services/database/SqliteDatabaseAccessService.js b/packages/backend/src/services/database/SqliteDatabaseAccessService.js index ccf191aa..d38ddf66 100644 --- a/packages/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/packages/backend/src/services/database/SqliteDatabaseAccessService.js @@ -42,7 +42,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { this.db = new Database(this.config.path); // Database upgrade logic - const TARGET_VERSION = 5; + const TARGET_VERSION = 6; if ( do_setup ) { this.log.noticeme(`SETUP: creating database at ${this.config.path}`); @@ -54,6 +54,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { '0005_background-apps.sql', '0006_update-apps.sql', '0007_sessions.sql', + '0008_otp.sql', ].map(p => path_.join(__dirname, 'sqlite_setup', p)); const fs = require('fs'); for ( const filename of sql_files ) { @@ -90,6 +91,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { upgrade_files.push('0007_sessions.sql'); } + if ( user_version <= 5 ) { + upgrade_files.push('0008_otp.sql'); + } + if ( upgrade_files.length > 0 ) { this.log.noticeme(`Database out of date: ${this.config.path}`); this.log.noticeme(`UPGRADING DATABASE: ${user_version} -> ${TARGET_VERSION}`); diff --git a/packages/backend/src/services/database/sqlite_setup/0008_otp.sql b/packages/backend/src/services/database/sqlite_setup/0008_otp.sql new file mode 100644 index 00000000..c9ec6b3b --- /dev/null +++ b/packages/backend/src/services/database/sqlite_setup/0008_otp.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN "otp_secret" TEXT DEFAULT NULL;