Implement backend for 2FA

This commit is contained in:
KernelDeimos 2024-04-30 16:56:11 -04:00
parent 038373cbbc
commit d7c5c37cf8
10 changed files with 227 additions and 23 deletions

View File

@ -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",

View File

@ -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 }) => {

View File

@ -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': {

View File

@ -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);
});

View File

@ -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
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;

View File

@ -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'))

View File

@ -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 };

View File

@ -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;

View File

@ -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}`);

View File

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