Explorar el Código

Implement backend for 2FA

KernelDeimos hace 1 año
padre
commit
d7c5c37cf8

+ 2 - 0
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",

+ 3 - 0
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 }) => {

+ 4 - 0
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': {

+ 52 - 0
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);
+});

+ 99 - 23
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,
-            });
-
-            // 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),
-                }
-            })
+            // 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' });
+
+                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
+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;

+ 1 - 0
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'))

+ 56 - 0
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 };

+ 4 - 0
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;
 

+ 6 - 1
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}`);

+ 1 - 0
packages/backend/src/services/database/sqlite_setup/0008_otp.sql

@@ -0,0 +1 @@
+ALTER TABLE user ADD COLUMN "otp_secret" TEXT DEFAULT NULL;