Skip to content

Commit 5feb676

Browse files
committed
Check whether a person has a verified email with GET /status/:webId
1 parent 89753f7 commit 5feb676

18 files changed

+640
-233
lines changed

.env.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ MAILER_IDENTITY_EMAIL=bot@example
1414
MAILER_IDENTITY_PASSWORD=password
1515
MAILER_IDENTITY_PROVIDER=http://localhost:3456
1616
MAILER_IDENTITY_WEBID=http://localhost:3456/bot/profile/card#me
17+
MAILER_IDENTITY_CSS_VERSION=7 # supported versions are 6 and 7
1718

1819
# link to group of users who are allowed to use the service
1920
ALLOWED_GROUPS=
@@ -50,3 +51,6 @@ DB_PORT=
5051
JWT_KEY=./ecdsa-p256-private.pem
5152
# jwt algorithm
5253
JWT_ALG=ES256
54+
55+
# type index class by which we find settings with email
56+
EMAIL_DISCOVERY_TYPE=http://w3id.org/hospex/ns#PersonalHospexDocument

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
# Simple Solid email notifications
22

3-
This service sends email from one person within a Solid group to another. It doesn't use native Solid notifications, because there are pods that don't support it.
3+
This service sends email from one person within a Solid group to another. It doesn't use native Solid notifications, because there are pods that don't support them.
44

55
## How it works
66

7-
- It's a bot agent with its own identity, which must be provided by arbitrary CommunitySolidServer pod
7+
- It's a bot agent with its own identity, which must be provided by arbitrary [CommunitySolidServer](https://github.com/CommunitySolidServer/CommunitySolidServer) pod
88
- It runs on a server
99
- You can check whether another person in the group has set up email notifications.
1010
- If the other person has set up the notifications, you can send them an email through this service.
1111
- At the beginning, you need to verify your email, save it into your hospitality exchange settings, and give the mailer a permission to read the email; so the email notifier can access the settings when it sends you an email.
1212
- When you want to notify other person, the service will check whether both of you belong to the specified group(s). If you both belong, and the other person has email notifications set up, it will send the other person an email.
1313

14+
### Verified email address discovery
15+
16+
Email address and verification token should be stored in (webId) - space:preferencesFile -> (email settings file) to which the mailer identity has read (and maybe write) access. It can be in the main webId file, or it can be in some document discovered via publicTypeIndex. In case of hospitality exchange, it can be in hospex:PersonalHospexDocument.
17+
18+
1. Go to person's webId
19+
1. Find public type index `(webId) - solid:publicTypeIndex -> (publicTypeIndex)``
20+
1. Find instances of hospex:PersonalHospexDocument
21+
1. Find settings in the relevant instance (webId) - space:preferencesFile -> (settings)
22+
1. In the settings, find (webId) - foaf:mbox -> (email) and (webId) - example:emailVerificationToken -> (JWT)
23+
1424
## Usage
1525

1626
### Configure

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@koa/bodyparser": "^5.0.0",
5454
"@koa/cors": "^4.0.0",
5555
"@koa/router": "^12.0.0",
56+
"@ldhop/core": "^0.0.0-alpha.0",
5657
"@solid/access-token-verifier": "^2.0.5",
5758
"ajv": "^8.12.0",
5859
"ajv-formats": "^2.1.1",

src/app.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
} from './controllers/integration'
1313
import { getStatus } from './controllers/status'
1414
import { webhookReceiver } from './controllers/webhookReceiver'
15-
import { authorizeGroups } from './middlewares/authorizeGroup'
15+
import {
16+
authorizeGroups,
17+
checkParamGroupMembership,
18+
} from './middlewares/authorizeGroup'
1619
import { solidAuth } from './middlewares/solidAuth'
1720
import { validateBody } from './middlewares/validate'
1821

@@ -90,7 +93,13 @@ router
9093
)
9194
.get('/verify-email', checkVerificationLink, finishIntegration)
9295
.post('/webhook-receiver', webhookReceiver)
93-
.get('/status', solidAuth, getStatus)
96+
.get(
97+
'/status/:webId',
98+
solidAuth,
99+
authorizeGroups(allowedGroups),
100+
checkParamGroupMembership(allowedGroups, 'webId' as const),
101+
getStatus,
102+
)
94103

95104
app
96105
.use(helmet())

src/config/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const mailerCredentials = {
1616
webId:
1717
process.env.MAILER_IDENTITY_WEBID ??
1818
'http://localhost:3456/bot/profile/card#me',
19+
// version of CommunitySolidServer that provides identity for mailer
20+
cssVersion: <6 | 7>+(process.env.MAILER_IDENTITY_CSS_VERSION ?? 7), // 6 or 7
1921
}
2022

2123
const stringToBoolean = (value: string | undefined): boolean => {
@@ -71,3 +73,7 @@ export const jwt = {
7173
key: process.env.JWT_KEY ?? './ecdsa-p256-private.pem',
7274
alg: process.env.JWT_ALG ?? 'ES256',
7375
}
76+
77+
export const emailDiscoveryType =
78+
process.env.EMAIL_DISCOVERY_TYPE ??
79+
'http://w3id.org/hospex/ns#PersonalHospexDocument'

src/controllers/status.ts

Lines changed: 157 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,159 @@
1-
import { Middleware } from 'koa'
2-
import { EmailVerification, Integration } from '../config/sequelize'
3-
4-
export const getStatus: Middleware = async ctx => {
5-
const webId = ctx.state.user
6-
7-
const verified = await Integration.findAll({ where: { webId } })
8-
9-
const unverified = await EmailVerification.findAll({ where: { webId } })
10-
11-
ctx.response.body = {
12-
actor: webId,
13-
integrations: [
14-
...verified.map(s => ({
15-
object: s.inbox,
16-
target: s.email,
17-
verified: true,
18-
})),
19-
...unverified.map(s => ({
20-
object: s.inbox,
21-
target: s.email,
22-
verified: false,
23-
})),
24-
],
25-
}
1+
import { RdfQuery } from '@ldhop/core/dist/src'
2+
import { QueryAndStore } from '@ldhop/core/dist/src/QueryAndStore'
3+
import { fetchRdfDocument } from '@ldhop/core/dist/src/utils/helpers'
4+
import { getAuthenticatedFetch as getAuthenticatedFetch6x } from 'css-authn/dist/6.x'
5+
import { getAuthenticatedFetch as getAuthenticatedFetch7x } from 'css-authn/dist/7.x'
6+
import { readFile } from 'fs-extra'
7+
import { verify } from 'jsonwebtoken'
8+
import type { DefaultContext, DefaultState, Middleware } from 'koa'
9+
import { NamedNode, Quad } from 'n3'
10+
import { dct, rdfs, solid, space } from 'rdf-namespaces'
11+
import * as config from '../config'
12+
13+
export const getVerifiedEmails = async (webId: string) => {
14+
const tokens = await findEmailVerificationTokens(webId)
15+
16+
const pem = await readFile(config.jwt.key, { encoding: 'utf-8' })
17+
18+
const verifiedEmails = tokens
19+
.map(token => {
20+
try {
21+
return verify(token, pem) as {
22+
webId: string
23+
email: string
24+
emailVerified: boolean
25+
iss: string
26+
iat: number
27+
}
28+
} catch {
29+
return null
30+
}
31+
})
32+
.filter(a => a?.emailVerified && a.webId === webId)
33+
.map(a => a!.email)
34+
35+
return verifiedEmails
36+
}
37+
38+
export const getStatus: Middleware<
39+
DefaultState,
40+
DefaultContext & { params: { webId: string } }
41+
> = async ctx => {
42+
const webId = ctx.params.webId
43+
44+
const verifiedEmails = await getVerifiedEmails(webId)
45+
46+
ctx.response.body = { emailVerified: verifiedEmails.length > 0 }
2647
ctx.response.status = 200
2748
}
49+
50+
/**
51+
* To find verified email of a person
52+
*
53+
* - Go to person's webId
54+
* - Find public type index `(webId) - solid:publicTypeIndex -> (publicTypeIndex)``
55+
* - Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE defaults to hospex:PersonalHospexDocument)
56+
* - Find settings in the relevant instance (webId) - space:preferencesFile -> (settings)
57+
* - In the settings, find (webId) - example:emailVerificationToken -> (JWT)
58+
*/
59+
const findEmailQuery: RdfQuery = [
60+
// Go to person's webId and fetch extended profile documents, too
61+
{
62+
type: 'match',
63+
subject: '?person',
64+
predicate: rdfs.seeAlso,
65+
pick: 'object',
66+
target: '?extendedDocument',
67+
},
68+
{ type: 'add resources', variable: '?extendedDocument' },
69+
// Find public type index
70+
{
71+
type: 'match',
72+
subject: '?person',
73+
predicate: solid.publicTypeIndex,
74+
pick: 'object',
75+
target: '?publicTypeIndex',
76+
},
77+
// Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE)
78+
{
79+
type: 'match',
80+
subject: '?publicTypeIndex',
81+
predicate: dct.references,
82+
pick: 'object',
83+
target: '?typeRegistration',
84+
},
85+
{
86+
type: 'match',
87+
subject: '?typeRegistration',
88+
predicate: solid.forClass,
89+
object: config.emailDiscoveryType,
90+
pick: 'subject',
91+
target: '?typeRegistrationForClass',
92+
},
93+
{
94+
type: 'match',
95+
subject: '?typeRegistrationForClass',
96+
predicate: solid.instance,
97+
pick: 'object',
98+
target: `?classDocument`,
99+
},
100+
{ type: 'add resources', variable: '?classDocument' },
101+
// Find settings
102+
{
103+
type: 'match',
104+
subject: '?person',
105+
predicate: space.preferencesFile,
106+
pick: 'object',
107+
target: '?settings',
108+
},
109+
{ type: 'add resources', variable: '?settings' },
110+
]
111+
112+
const findEmailVerificationTokens = async (webId: string) => {
113+
// initialize knowledge graph and follow your nose through it
114+
// according to the query
115+
const qas = new QueryAndStore(findEmailQuery, { person: new Set([webId]) })
116+
await run(qas)
117+
118+
// Find email verification tokens
119+
const objects = qas.store.getObjects(
120+
new NamedNode(webId),
121+
new NamedNode('https://example.com/emailVerificationToken'),
122+
null,
123+
)
124+
125+
return objects.map(o => o.value)
126+
}
127+
128+
const fetchRdf = async (uri: string) => {
129+
const getAuthenticatedFetch =
130+
config.mailerCredentials.cssVersion === 6
131+
? getAuthenticatedFetch6x
132+
: getAuthenticatedFetch7x
133+
const authBotFetch = await getAuthenticatedFetch(config.mailerCredentials)
134+
135+
const { data: quads } = await fetchRdfDocument(uri, authBotFetch)
136+
137+
return quads
138+
}
139+
140+
/**
141+
* Follow your nose through the linked data graph by query
142+
*/
143+
const run = async (qas: QueryAndStore) => {
144+
let missingResources = qas.getMissingResources()
145+
146+
while (missingResources.length > 0) {
147+
let quads: Quad[] = []
148+
const res = missingResources[0]
149+
try {
150+
quads = await fetchRdf(missingResources[0])
151+
} catch (e) {
152+
// eslint-disable-next-line no-console
153+
console.error(e)
154+
} finally {
155+
qas.addResource(res, quads)
156+
missingResources = qas.getMissingResources()
157+
}
158+
}
159+
}

src/helpers.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/middlewares/authorizeGroup.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
1-
import { Middleware } from 'koa'
1+
import { DefaultContext, DefaultState, Middleware } from 'koa'
22
import { Parser } from 'n3'
33
import { vcard } from 'rdf-namespaces'
44

55
export const authorizeGroups =
6-
(groups: string[]): Middleware =>
6+
(groups: string[]): Middleware<{ user: string }> =>
77
async (ctx, next) => {
88
// if array of groups are empty, we allow everybody (default)
99
if (groups.length === 0) return await next()
1010

11-
const user = <string>ctx.state.user
11+
const user = ctx.state.user
1212

13-
const memberships = await Promise.allSettled(
14-
groups.map(group => isGroupMember(user, group)),
15-
)
16-
17-
const isAllowed = memberships.some(
18-
membership =>
19-
membership.status === 'fulfilled' && membership.value === true,
20-
)
13+
const isAllowed = await isSomeGroupMember(user, groups)
2114

2215
if (!isAllowed) {
2316
return ctx.throw(
@@ -29,6 +22,46 @@ export const authorizeGroups =
2922
await next()
3023
}
3124

25+
const isSomeGroupMember = async (user: string, groups: string[]) => {
26+
const memberships = await Promise.allSettled(
27+
groups.map(group => isGroupMember(user, group)),
28+
)
29+
30+
const isMember = memberships.some(
31+
membership =>
32+
membership.status === 'fulfilled' && membership.value === true,
33+
)
34+
return isMember
35+
}
36+
37+
/**
38+
* Check whether a user specified in param is member of any of the given groups
39+
*/
40+
export const checkParamGroupMembership =
41+
<T extends string>(
42+
groups: string[],
43+
param: T,
44+
): Middleware<
45+
DefaultState,
46+
DefaultContext & { params: { [K in T]: string } }
47+
> =>
48+
async (ctx, next) => {
49+
// if array of groups are empty, we allow everybody (default)
50+
if (groups.length === 0) return await next()
51+
const webId = ctx.params[param]
52+
const isAllowed = await isSomeGroupMember(webId, groups)
53+
54+
if (!isAllowed) {
55+
return ctx.throw(400, {
56+
error: 'Person is not a member of any allowed group',
57+
person: webId,
58+
groups,
59+
})
60+
}
61+
62+
await next()
63+
}
64+
3265
const isGroupMember = async (user: string, group: string) => {
3366
const groupDocumentResponse = await fetch(group)
3467
if (!groupDocumentResponse.ok) return false

src/middlewares/solidAuth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import type {
55
import * as verifier from '@solid/access-token-verifier'
66
import type { Middleware } from 'koa'
77

8-
export const solidAuth: Middleware = async (ctx, next) => {
8+
export const solidAuth: Middleware<{
9+
user: string
10+
client: string | undefined
11+
}> = async (ctx, next) => {
912
const authorizationHeader = ctx.request.headers.authorization
1013
const dpopHeader = ctx.request.headers.dpop
1114
const solidOidcAccessTokenVerifier: SolidTokenVerifierFunction =

0 commit comments

Comments
 (0)