Skip to content
Open
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
34 changes: 34 additions & 0 deletions backend/internal/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import authModel from "../models/auth.js";
import TokenModel from "../models/token.js";
import userModel from "../models/user.js";
import twoFactor from "./2fa.js";
import webauthn from "./webauthn.js";

const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password";
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth";
Expand Down Expand Up @@ -210,6 +211,39 @@ export default {
};
},

/**
* Verify passkey authentication and return full token
* @param {string} challengeToken
* @param {Object} credential
* @param {string} [expiry]
* @returns {Promise}
*/
getTokenFromPasskey: async (challengeToken, credential, expiry, req) => {
const Token = TokenModel();
const tokenExpiry = expiry || "1d";

const userId = await webauthn.verifyAuthentication(challengeToken, credential, req);

const expiryDate = parseDatePeriod(tokenExpiry);
if (expiryDate === null) {
throw new errs.AuthError(`Invalid expiry time: ${tokenExpiry}`);
}

const signed = await Token.create({
iss: "api",
attrs: {
id: userId,
},
scope: ["user"],
expiresIn: tokenExpiry,
});

return {
token: signed.token,
expires: expiryDate.toISOString(),
};
},

/**
* @param {Object} user
* @returns {Promise}
Expand Down
107 changes: 92 additions & 15 deletions backend/internal/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import utils from "../lib/utils.js";
import authModel from "../models/auth.js";
import userModel from "../models/user.js";
import userPermissionModel from "../models/user_permission.js";
import webauthnCredentialModel from "../models/webauthn_credential.js";
import internalAuditLog from "./audit-log.js";
import internalToken from "./token.js";

Expand Down Expand Up @@ -170,19 +171,30 @@ const internalUser = {

return query.then(utils.omitRow(omissions()));
})
.then((row) => {
.then(async (row) => {
if (!row || !row.id) {
throw new errs.ItemNotFoundError(thisData.id);
}
// Custom omissions
if (typeof thisData.omit !== "undefined" && thisData.omit !== null) {
return _.omit(row, thisData.omit);
}

if (row.avatar === "") {
row.avatar = DEFAULT_AVATAR;
}

// Include has_password when user is fetching themselves or is an admin
if (row.id === access.token.getUserId(0) || access.token.hasScope("admin")) {
const passwordAuth = await authModel
.query()
.where("user_id", row.id)
.andWhere("type", "password")
.first();
row.has_password = !!passwordAuth;
}

// Custom omissions
if (typeof thisData.omit !== "undefined" && thisData.omit !== null) {
return _.omit(row, thisData.omit);
}

return row;
});
},
Expand Down Expand Up @@ -350,7 +362,7 @@ const internalUser = {
.then(() => {
return internalUser.get(access, { id: data.id });
})
.then((user) => {
.then(async (user) => {
if (user.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new errs.InternalValidationError(
Expand All @@ -359,19 +371,25 @@ const internalUser = {
}

if (user.id === access.token.getUserId(0)) {
// they're setting their own password. Make sure their current password is correct
if (typeof data.current === "undefined" || !data.current) {
throw new errs.ValidationError("Current password was not supplied");
}
// Check if this user already has a password set
const existingAuth = await authModel
.query()
.where("user_id", user.id)
.andWhere("type", "password")
.first();

if (existingAuth) {
// Has password — require current password
if (typeof data.current === "undefined" || !data.current) {
throw new errs.ValidationError("Current password was not supplied");
}

return internalToken
.getTokenFromEmail({
await internalToken.getTokenFromEmail({
identity: user.email,
secret: data.current,
})
.then(() => {
return user;
});
}
// No password — skip current password check, allow setting a new one
}

return user;
Expand Down Expand Up @@ -423,6 +441,65 @@ const internalUser = {
* @param {Object} data
* @return {Promise}
*/
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.current]
* @return {Promise}
*/
removePassword: async (access, data) => {
await access.can("users:password", data.id);

const user = await internalUser.get(access, { id: data.id });
if (user.id !== data.id) {
throw new errs.InternalValidationError(
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
);
}

// Verify user has at least one passkey as an alternative auth method
const passkeys = await webauthnCredentialModel
.query()
.where("user_id", user.id)
.andWhere("is_deleted", 0);

if (passkeys.length === 0) {
throw new errs.ValidationError("Cannot remove password without an alternative authentication method (passkey)");
}

// If user is removing their own password, verify current password
if (user.id === access.token.getUserId(0)) {
if (typeof data.current === "undefined" || !data.current) {
throw new errs.ValidationError("Current password was not supplied");
}

await internalToken.getTokenFromEmail({
identity: user.email,
secret: data.current,
});
}

// Delete the password auth row
await authModel
.query()
.where("user_id", user.id)
.andWhere("type", "password")
.delete();

await internalAuditLog.add(access, {
action: "updated",
object_type: "user",
object_id: user.id,
meta: {
name: user.name,
password_removed: true,
},
});

return true;
},

setPermissions: (access, data) => {
return access
.can("users:permissions", data.id)
Expand Down
Loading