Skip to content

Commit

Permalink
Check that the username matches the registered name (#1875)
Browse files Browse the repository at this point in the history
* Check that the username matches the registered name

* Feedback

* Register name along with contract when signing up

* Documentation
  • Loading branch information
corrideat authored Mar 13, 2024
1 parent e30cc51 commit d7aa2d7
Show file tree
Hide file tree
Showing 10 changed files with 167 additions and 46 deletions.
19 changes: 19 additions & 0 deletions backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ route.POST('/event', {
const deserializedHEAD = GIMessage.deserializeHEAD(request.payload)
try {
await sbp('backend/server/handleEntry', deserializedHEAD, request.payload)
const name = request.headers['shelter-namespace-registration']
// If this is the first message in a contract and the
// `shelter-namespace-registration` header is present, proceed with also
// registering a name for the new contract
if (deserializedHEAD.contractID === deserializedHEAD.hash && name && !name.startsWith('_private')) {
// Name registation is enabled only for identity contracts
const cheloniaState = sbp('chelonia/private/state')
if (cheloniaState.contracts[deserializedHEAD.contractID]?.type === 'gi.contracts/identity') {
const r = await sbp('backend/db/registerName', name, deserializedHEAD.contractID)
if (Boom.isBoom(r)) {
return r
}
}
}
} catch (err) {
console.error(err, chalk.bold.yellow(err.name))
if (err.name === 'ChelErrorDBBadPreviousHEAD' || err.name === 'ChelErrorAlreadyProcessed') {
Expand Down Expand Up @@ -129,6 +143,10 @@ route.GET('/eventsBetween/{startHash}/{endHash}', {}, async function (request, h
}
})

/*
// The following endpoint is disabled because name registrations are handled
// through the `shelter-namespace-registration` header when registering a
// new contract
route.POST('/name', {
validate: {
payload: Joi.object({
Expand All @@ -146,6 +164,7 @@ route.POST('/name', {
return err
}
})
*/

route.GET('/name/{name}', {}, async function (request, h) {
const { name } = request.params
Expand Down
4 changes: 2 additions & 2 deletions frontend/controller/actions/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ export default (sbp('sbp/selectors/register', {
],
data: {
attributes: { username, email, picture: finalPicture }
}
},
namespaceRegistration: username
})

userID = user.contractID()
Expand Down Expand Up @@ -238,7 +239,6 @@ export default (sbp('sbp/selectors/register', {
},
publishOptions
})
await sbp('namespace/register', username, userID)
return userID
} catch (e) {
await sbp('gi.actions/identity/logout') // TODO: should this be here?
Expand Down
1 change: 1 addition & 0 deletions frontend/controller/actions/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type GIRegParams = {
contractID?: string; // always set on contract actions, but not when creating a contract
data: Object;
options?: Object; // these are options for the action wrapper
namespaceRegistration: ?string;
hooks?: Object;
publishOptions?: Object
}
Expand Down
24 changes: 19 additions & 5 deletions frontend/controller/namespace.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import sbp from '@sbp/sbp'
import Vue from 'vue'
import { handleFetchResult } from './utils/misc.js'

// NOTE: prefix groups with `group/` and users with `user/` ?
sbp('sbp/selectors/register', {
/*
// Registration is done when creating a contract, using the
// `shelter-namespace-registration` header
'namespace/register': (name: string, value: string) => {
return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name`, {
method: 'POST',
Expand All @@ -18,16 +20,27 @@ sbp('sbp/selectors/register', {
return result
})
},
'namespace/lookup': (name: string) => {
// TODO: should `name` be encodeURI'd?
*/
'namespace/lookupCached': (name: string) => {
const cache = sbp('state/vuex/state').namespaceLookups
if (name in cache) {
// Wrapping in a Promise to return a consistent type across all execution
// paths (next return is a Promise)
// This way we can call .then() on the result
return Promise.resolve(cache[name])
return cache[name]
}
return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${name}`).then((r: Object) => {
},
'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => {
if (!skipCache) {
const cached = sbp('namespace/lookupCached', name)
if (cached) {
// Wrapping in a Promise to return a consistent type across all execution
// paths (next return is a Promise)
// This way we can call .then() on the result
return Promise.resolve(cached)
}
}
return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${encodeURIComponent(name)}`).then((r: Object) => {
if (!r.ok) {
console.warn(`namespace/lookup: ${r.status} for ${name}`)
if (r.status !== 404) {
Expand All @@ -37,6 +50,7 @@ sbp('sbp/selectors/register', {
}
return r['text']()
}).then(value => {
const cache = sbp('state/vuex/state').namespaceLookups
if (value !== null) {
Vue.set(cache, name, value)
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async function startApp () {
defaults: {
modules: { '@common/common.js': Common },
allowedSelectors: [
'namespace/lookup',
'namespace/lookup', 'namespace/lookupCached',
'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters',
'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/cancelRemove', 'controller/router',
'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName',
Expand Down
108 changes: 81 additions & 27 deletions frontend/model/contracts/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,63 @@ import {

import { IDENTITY_USERNAME_MAX_CHARS } from './shared/constants.js'

const attributesType = objectMaybeOf({
username: string,
email: string,
picture: unionOf(string, objectOf({
manifestCid: string,
downloadParams: optional(object)
}))
})

const validateUsername = (username: string) => {
if (!username) {
throw new TypeError('A username is required')
}
if (username.length > IDENTITY_USERNAME_MAX_CHARS) {
throw new TypeError(`A username cannot exceed ${IDENTITY_USERNAME_MAX_CHARS} characters.`)
}
if (!allowedUsernameCharacters(username)) {
throw new TypeError('A username cannot contain disallowed characters.')
}
if (!noConsecutiveHyphensOrUnderscores(username)) {
throw new TypeError('A username cannot contain two consecutive hyphens or underscores.')
}
if (!noLeadingOrTrailingHyphen(username)) {
throw new TypeError('A username cannot start or end with a hyphen.')
}
if (!noLeadingOrTrailingUnderscore(username)) {
throw new TypeError('A username cannot start or end with an underscore.')
}
if (!noUppercase(username)) {
throw new TypeError('A username cannot contain uppercase letters.')
}
}

const checkUsernameConsistency = async (contractID: string, username: string) => {
// Lookup and save the username so that we can verify that it matches
const lookupResult = await sbp('namespace/lookup', username, { skipCache: true })
if (lookupResult === contractID) return

console.error(`Mismatched username. The lookup result was ${lookupResult} instead of ${contractID}`)

// If there was a mismatch, wait until the contract is finished processing
// (because the username could have been updated), and if the situation
// persists, warn the user
sbp('chelonia/queueInvocation', contractID, () => {
const rootState = sbp('state/vuex/state')
if (!has(rootState, contractID)) return

const username = rootState[contractID].attributes.username
if (sbp('namespace/lookupCached', username) !== contractID) {
sbp('gi.notifications/emit', 'WARNING', {
contractID,
message: L('Unable to confirm that the username {username} belongs to this identity contract', { username })
})
}
})
}

sbp('chelonia/defineContract', {
name: 'gi.contracts/identity',
getters: {
Expand All @@ -31,34 +88,13 @@ sbp('chelonia/defineContract', {
'gi.contracts/identity': {
validate: (data, { state }) => {
objectMaybeOf({
attributes: objectMaybeOf({
username: string,
email: string,
picture: unionOf(string, objectOf({
manifestCid: string,
downloadParams: optional(object)
}))
})
attributes: attributesType
})(data)
const { username } = data.attributes
if (username.length > IDENTITY_USERNAME_MAX_CHARS) {
throw new TypeError(`A username cannot exceed ${IDENTITY_USERNAME_MAX_CHARS} characters.`)
}
if (!allowedUsernameCharacters(username)) {
throw new TypeError('A username cannot contain disallowed characters.')
}
if (!noConsecutiveHyphensOrUnderscores(username)) {
throw new TypeError('A username cannot contain two consecutive hyphens or underscores.')
}
if (!noLeadingOrTrailingHyphen(username)) {
throw new TypeError('A username cannot start or end with a hyphen.')
}
if (!noLeadingOrTrailingUnderscore(username)) {
throw new TypeError('A username cannot start or end with an underscore.')
}
if (!noUppercase(username)) {
throw new TypeError('A username cannot contain uppercase letters.')
if (!username) {
throw new TypeError('A username is required')
}
validateUsername(username)
},
process ({ data }, { state }) {
const initialState = merge({
Expand All @@ -70,18 +106,36 @@ sbp('chelonia/defineContract', {
for (const key in initialState) {
Vue.set(state, key, initialState[key])
}
},
async sideEffect ({ contractID, data }) {
await checkUsernameConsistency(contractID, data.attributes.username)
}
},
'gi.contracts/identity/setAttributes': {
validate: object,
validate: (data) => {
attributesType(data)
if (has(data, 'username')) {
validateUsername(data.username)
}
},
process ({ data }, { state }) {
for (const key in data) {
Vue.set(state.attributes, key, data[key])
}
},
async sideEffect ({ contractID, data }) {
if (has(data, 'username')) {
await checkUsernameConsistency(contractID, data.username)
}
}
},
'gi.contracts/identity/deleteAttributes': {
validate: arrayOf(string),
validate: (data) => {
arrayOf(string)(data)
if (data.includes('username')) {
throw new Error('Username can\'t be deleted')
}
},
process ({ data }, { state }) {
for (const attribute of data) {
Vue.delete(state.attributes, attribute)
Expand Down
28 changes: 22 additions & 6 deletions frontend/model/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ if (window.matchMedia) {
})
}

const checkedUsername = (state: Object, username: string, userID: string) => {
if (username && state.namespaceLookups[username] === userID) {
return username
}
}

const reactiveDate = Vue.observable({ date: new Date() })
setInterval(function () {
// We want the getters to recalculate all of the payments within 1 minute of us entering a new period.
Expand Down Expand Up @@ -227,12 +233,12 @@ const getters = {
}
},
userDisplayNameFromID (state, getters) {
return (user) => {
if (user === getters.ourIdentityContractId) {
return (userID) => {
if (userID === getters.ourIdentityContractId) {
return getters.ourUserDisplayName
}
const profile = getters.ourContactProfilesById[user] || {}
return profile.displayName || profile.username || user
const profile = getters.ourContactProfilesById[userID] || {}
return profile.displayName || profile.username || userID
}
},
// this getter gets recomputed automatically according to the setInterval on reactiveDate
Expand Down Expand Up @@ -468,7 +474,12 @@ const getters = {
.forEach(contractID => {
const attributes = state[contractID].attributes
if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts
profiles[attributes.username] = { ...attributes, contractID }
const username = checkedUsername(state, attributes.username, contractID)
profiles[attributes.username] = {
...attributes,
username,
contractID
}
}
})
return profiles
Expand All @@ -480,7 +491,12 @@ const getters = {
.forEach(contractID => {
const attributes = state[contractID].attributes
if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts
profiles[contractID] = { ...attributes, contractID }
const username = checkedUsername(state, attributes.username, contractID)
profiles[contractID] = {
...attributes,
username,
contractID
}
}
})
return profiles
Expand Down
13 changes: 11 additions & 2 deletions shared/domains/chelonia/chelonia.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type ChelRegParams = {
actionSigningKeyId: string;
actionEncryptionKeyId: ?string;
keys: (GIKey | EncryptedData<GIKey>)[];
namespaceRegistration: ?string;
hooks?: {
prepublishContract?: (GIMessage) => void;
postpublishContract?: (GIMessage) => void;
Expand All @@ -40,7 +41,7 @@ export type ChelRegParams = {
postpublish?: (GIMessage) => void;
onprocessed?: (GIMessage) => void;
};
publishOptions?: { maxAttempts: number };
publishOptions?: { headers: ?Object, maxAttempts: number };
}

export type ChelActionParams = {
Expand Down Expand Up @@ -923,7 +924,15 @@ export default (sbp('sbp/selectors/register', {
manifest: manifestHash
})
const contractID = contractMsg.hash()
await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, hooks && {
await sbp('chelonia/private/out/publishEvent', contractMsg, (params.namespaceRegistration
? {
...publishOptions,
headers: {
...publishOptions?.headers,
'shelter-namespace-registration': params.namespaceRegistration
}
}
: publishOptions), hooks && {
prepublish: hooks.prepublishContract,
postpublish: hooks.postpublishContract
})
Expand Down
3 changes: 2 additions & 1 deletion shared/domains/chelonia/internals.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ export default (sbp('sbp/selectors/register', {
},
// used by, e.g. 'chelonia/contract/wait'
'chelonia/private/noop': function () {},
'chelonia/private/out/publishEvent': function (entry: GIMessage, { maxAttempts = 5 } = {}, hooks) {
'chelonia/private/out/publishEvent': function (entry: GIMessage, { maxAttempts = 5, headers } = {}, hooks) {
const contractID = entry.contractID()
const originalEntry = entry

Expand Down Expand Up @@ -488,6 +488,7 @@ export default (sbp('sbp/selectors/register', {
method: 'POST',
body: entry.serialize(),
headers: {
...headers,
'Content-Type': 'text/plain',
'Authorization': 'gi TODO - signature - if needed here - goes here'
},
Expand Down
Loading

0 comments on commit d7aa2d7

Please sign in to comment.