Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions lib/db/models/all/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,14 @@ export const lockDeviceByCurrent = function(groups, serial) {
'group.lock': {
$cond: [
{
// A device without the field is not locked
$and: [
{$eq: ['$group?.lock', false]},
{$not: [{$eq: [{$setIntersection: [groups, ['$group?.id']]}, []]}]}
{$ne: ['$group.lock', true]},
{$in: ['$group.id', groups]}
]
},
true,
'$group?.lock'
'$group.lock'
]
}
}
Expand Down Expand Up @@ -231,13 +232,14 @@ export const lockDeviceByOrigin = function(groups, serial) {
'group.lock': {
$cond: [
{
// A device without the field is not locked
$and: [
{$eq: ['$group?.lock', false]},
{$not: [{$eq: [{$setIntersection: [groups, ['$group?.origin']]}, []]}]}
{$ne: ['$group.lock', true]},
{$in: ['$group.origin', groups]}
]
},
true,
'$group?.lock'
'$group.lock'
]
}
}
Expand Down Expand Up @@ -1013,7 +1015,9 @@ export const saveUserAccessToken = function(email, token) {
email: email,
id: token.id,
title: token.title,
jwt: token.jwt
jwt: token.jwt,
// Expiration time in ms, null if it never expires
expiresAt: token.expiresAt ?? null
}).then(function(result) {
if (result.insertedId) {
return tokenId
Expand Down Expand Up @@ -1346,16 +1350,16 @@ export const updateDeviceGroupName = async(serial, group) => {
$eq: [group?.isActive, true]
},
group?.name,
'$group?.name'
'$group.name'
]
},
{
$cond: [
{
$eq: ['$group?.origin', '$group?.id']
$eq: ['$group.origin', '$group.id']
},
group?.name,
'$group?.name'
'$group.name'
]
}
]
Expand All @@ -1366,7 +1370,7 @@ export const updateDeviceGroupName = async(serial, group) => {
$eq: [apiutil.isOriginGroup(group?.class), true]
},
group?.name,
'$group?.originName'
'$group.originName'
]
}
}
Expand Down
30 changes: 10 additions & 20 deletions lib/db/models/user/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,6 @@ export const loadUser = function(email) {
return db.users.findOne({email: email})
}

// dbapi.lookupUsersByAdbKey = function(fingerprint) {
export const lookupUsersByAdbKey = function(fingerprint) {
return db.users.find({
adbKeys: fingerprint
}).toArray()
}

// dbapi.lookupUserByAdbFingerprint = function(fingerprint) {
export const lookupUserByAdbFingerprint = function(fingerprint) {
return db.users.find(
Expand Down Expand Up @@ -193,19 +186,16 @@ export const updateUserSettings = async function(email, changes) {
}

// dbapi.insertUserAdbKey = function(email, key) {
export const insertUserAdbKey = function(email, key) {
let data = {
title: key.title,
fingerprint: key.fingerprint
}
return db.users.findOne({email: email}).then(user => {
let adbKeys = user?.adbKeys ? user?.adbKeys : []
adbKeys.push(data)
return db.users.updateOne(
{email: email}
, {$set: {adbKeys: user?.adbKeys ? adbKeys : [data]}}
)
})
/**
* One atomic update, so concurrent additions do not overwrite each other.
* @returns {Promise<boolean>} false if the user already has a key with this fingerprint
*/
export const insertUserAdbKey = async function(email, key) {
const result = await db.users.updateOne(
{email: email, 'adbKeys.fingerprint': {$ne: key.fingerprint}}
, {$push: {adbKeys: {title: key.title, fingerprint: key.fingerprint}}}
)
return result.modifiedCount > 0
}

// dbapi.grantAdmin = function(email) {
Expand Down
106 changes: 49 additions & 57 deletions lib/units/api/controllers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as apiutil from '../../../util/apiutil.js'
import * as lockutil from '../../../util/lockutil.js'
import * as Sentry from '@sentry/node'
import generateToken from '../helpers/generateToken.js'
import {AdbKeysUpdatedMessage, ConnectStartMessage, ConnectStopMessage, GroupMessage, OwnerMessage, UpdateAccessTokenMessage, UngroupMessage} from '../../../wire/wire.js'
import {ConnectStartMessage, ConnectStopMessage, GroupMessage, OwnerMessage, UpdateAccessTokenMessage, UngroupMessage} from '../../../wire/wire.js'
let log = logger.createLogger('api:controllers:user')

function getUser(req, res) {
Expand Down Expand Up @@ -118,7 +118,8 @@ function addUserDevice(req, res) {
}),
usage,
timeout: timeout || undefined,
keys: req.user.adbKeys.map(key => key.fingerprint)
// Absent for a user who never added an ADB key
keys: (req.user.adbKeys ?? []).map(key => key.fingerprint)
}
)
log.info(device.serial + ' added to user group ' + req.user.email)
Expand All @@ -128,6 +129,7 @@ function addUserDevice(req, res) {
})
}
catch (err) {
log.error('Failed to add device "%s" to user group %s: %s', device.serial, req.user.email, err?.message ?? err)
return apiutil.respond(res, 504, 'Device is not responding')
}
}
Expand Down Expand Up @@ -183,16 +185,31 @@ function deleteUserDeviceBySerial(req, res) {
}
}

await req.options.txmanager.runTransaction(
device.provider.name,
device.serial,
UngroupMessage,
{
requirements: wireutil.toDeviceRequirements({
serial: {value: serial, match: 'exact'}
})
}
)
try {
await req.options.txmanager.runTransaction(
device.provider.name,
device.serial,
UngroupMessage,
{
requirements: wireutil.toDeviceRequirements({
serial: {value: serial, match: 'exact'}
})
}
)
}
catch (err) {
log.error('Failed to release device "%s": %s', serial, err?.message ?? err)
return isInternal ? false : apiutil.respond(res, 504, 'Device is not responding')
}

// An internal call releases the devices of another request, which answers on its own
if (isInternal) {
return true
}
return res.json({
success: true,
description: 'Device successfully removed'
})
})
.catch(function(err) {
let errSerial
Expand All @@ -207,7 +224,7 @@ function deleteUserDeviceBySerial(req, res) {
return false
}
else {
apiutil.respond(res, 500, 'Internal Server Error' + err.message ? `: ${err.message}` : '')
apiutil.respond(res, 500, 'Internal Server Error' + (err.message ? `: ${err.message}` : ''))
}
})
}
Expand Down Expand Up @@ -339,50 +356,24 @@ function getUserAccessTokens(req, res) {
async function addAdbPublicKey(req, res) {
const data = req.body

let key
try {
// Parse the public key
const key = await Adb.util.parsePublicKey(data.publickey)

// Look up users by fingerprint
const adbKeys = await dbapi.lookupUsersByAdbKey(key.fingerprint)

const responseData = {
key: {
title: data.title || key.comment,
fingerprint: key.fingerprint
},
users: adbKeys
}

if (responseData.users.length) {
res.json({
success: true,
fingerprint: responseData.key.fingerprint
})
}
else {
try {
await dbapi.insertUserAdbKey(req.user.email, responseData.key)

res.json({
success: true,
fingerprint: responseData.key.fingerprint
})
}
catch (err) {
if (err instanceof dbapi.DuplicateSecondaryIndexError) {
return res.status(208).json({
success: true,
message: 'Key was already added'
})
}
throw err // rethrow so it goes to the outer catch
}
}

// Broadcast the update; the websocket unit fans it to interested clients.
req.options.transport.sendBroadcast(wireutil.pack(AdbKeysUpdatedMessage, {}))
key = await Adb.util.parsePublicKey(data?.publickey)
}
catch {
return apiutil.respond(res, 400, 'Invalid ADB public key')
}

try {
// Adding a key the user already has changes nothing
await dbapi.insertUserAdbKey(req.user.email, {
title: data.title || key.comment,
fingerprint: key.fingerprint
})
res.json({
success: true,
fingerprint: key.fingerprint
})
}
catch (err) {
log.error('Failed to insert a new ADB key fingerprint: ', err.stack)
Expand Down Expand Up @@ -464,11 +455,12 @@ function getAccessTokens(req, res) {

function createAccessToken(req, res) {
const title = req.query.title
const token = generateToken(req.user, req.options.secret)
const token = generateToken(req.user, req.options.secret, Number(req.query.lifetime ?? 0))
dbapi.saveUserAccessToken(req.user.email, {
title: title,
id: token.id,
jwt: token.jwt
jwt: token.jwt,
expiresAt: token.expiresAt
})
.then(function() {
req.options.transport.sendBroadcast(wireutil.pack(UpdateAccessTokenMessage, {}))
Expand Down
22 changes: 17 additions & 5 deletions lib/units/api/helpers/generateToken.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import * as jwtutil from '../../../util/jwtutil.js'
import {ONE_MONTH} from '../../../util/apiutil.js'
import util from 'util'
import {v4 as uuidv4} from 'uuid'

const generateToken = (/** @type {{ email: string; name: string; }} */ user, /** @type {string} */ secret) => {
/* Access token lifetimes in months; 0 never expires */
export const ACCESS_TOKEN_LIFETIMES = [0, 1, 2, 3]

const generateToken = (/** @type {{ email: string; name: string; }} */ user, /** @type {string} */ secret, lifetime = 0) => {
if (!ACCESS_TOKEN_LIFETIMES.includes(lifetime)) {
throw new Error(`Invalid access token lifetime: ${lifetime}`)
}

const id = util.format('%s-%s', uuidv4(), uuidv4()).replace(/-/g, '')
const expiresAt = lifetime ? Date.now() + lifetime * ONE_MONTH : null
const jwt = jwtutil.encode({
payload: {
email: user.email,
name: user.name
name: user.name,
// Lets the token be revoked by deleting it
jti: id
},
secret: secret
secret: secret,
exp: expiresAt
})
const id = util.format('%s-%s', uuidv4(), uuidv4()).replace(/-/g, '')
return {id: id, jwt: jwt}
return {id: id, jwt: jwt, expiresAt: expiresAt}
}

export default generateToken
14 changes: 13 additions & 1 deletion lib/units/api/helpers/securityHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import dbapi from '../../../db/api.js'
import * as jwtutil from '../../../util/jwtutil.js'
import logger from '../../../util/logger.js'
import * as apiutil from '../../../util/apiutil.js'
import {createAccessTokenCheck} from '../../../util/access-token-check.js'

const log = logger.createLogger('api:helpers:securityHandlers')
let upsertQueue

const accessTokenExists = createAccessTokenCheck(id => dbapi.loadAccessTokenById(id).then(Boolean))

// Specifications: https://tools.ietf.org/html/rfc6750#section-2.1
async function accessTokenAuth(req, res, next) {
let operationTag
Expand Down Expand Up @@ -46,6 +49,7 @@ async function accessTokenAuth(req, res, next) {
}

let data
let loaded = false
try {
data = jwtutil.decode(tokenId, req.options.secret)
}
Expand All @@ -58,6 +62,7 @@ async function accessTokenAuth(req, res, next) {
}
}
data = jwtutil.decode(token.jwt, req.options.secret)
loaded = true
}

if (!data?.email) {
Expand All @@ -67,6 +72,13 @@ async function accessTokenAuth(req, res, next) {
}
}

if (data.jti && !loaded && !await accessTokenExists(data.jti)) {
throw {
status: 401,
message: 'Revoked token'
}
}

await upsertQueue
const user = await dbapi.loadUser(data.email)

Expand Down Expand Up @@ -130,7 +142,7 @@ async function accessTokenAuth(req, res, next) {
}
}
try {
const user = dbapi.loadUser(data.email)
const user = await dbapi.loadUser(data.email)
if (user) {
if (isNotAllowed(user)) {
throw forbidden
Expand Down
Loading
Loading