mirror of
https://github.com/HeyPuter/puter
synced 2024-11-15 06:15:47 +00:00
Implement backend for 2FA
This commit is contained in:
parent
038373cbbc
commit
d7c5c37cf8
@ -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",
|
||||
|
@ -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 }) => {
|
||||
|
@ -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
packages/backend/src/routers/auth/configure-2fa.js
Normal file
52
packages/backend/src/routers/auth/configure-2fa.js
Normal 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);
|
||||
});
|
@ -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;
|
||||
|
@ -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
packages/backend/src/services/auth/OTPService.js
Normal file
56
packages/backend/src/services/auth/OTPService.js
Normal 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 };
|
@ -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;
|
||||
|
||||
|
@ -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}`);
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD COLUMN "otp_secret" TEXT DEFAULT NULL;
|
Loading…
Reference in New Issue
Block a user