@design-edito/cli 0.0.78 → 0.0.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/assets/list.txt +1 -2
- package/cli/assets/version.txt +1 -1
- package/make-template/assets/express/esbuild.config.js +19 -0
- package/make-template/assets/express/package.json +16 -12
- package/make-template/assets/express/src/index.ts +14 -50
- package/make-template/assets/express/src/public/index.css +7 -0
- package/make-template/assets/express/src/public/index.html +15 -0
- package/make-template/assets/express/src/public/index.js +31 -0
- package/make-template/assets/express/src/tsconfig.json +7 -3
- package/make-template/assets/express/src/www/index.ts +42 -0
- package/make-template/assets/express-api/Dockerfile +18 -0
- package/make-template/assets/express-api/Dockerfile.dev +8 -0
- package/make-template/assets/express-api/env +36 -0
- package/make-template/assets/express-api/esbuild.config.js +26 -0
- package/make-template/assets/express-api/gitignore +214 -0
- package/make-template/assets/express-api/package.json +60 -0
- package/make-template/assets/express-api/src/api/auth/_utils/index.ts +47 -0
- package/make-template/assets/express-api/src/api/auth/index.ts +25 -0
- package/make-template/assets/express-api/src/api/auth/login/index.ts +101 -0
- package/make-template/assets/express-api/src/api/auth/logout/index.ts +45 -0
- package/make-template/assets/express-api/src/api/auth/refresh-token/index.ts +54 -0
- package/make-template/assets/express-api/src/api/auth/request-email-verification-token/index.ts +45 -0
- package/make-template/assets/express-api/src/api/auth/request-new-password/index.ts +62 -0
- package/make-template/assets/express-api/src/api/auth/signup/index.ts +99 -0
- package/make-template/assets/express-api/src/api/auth/submit-new-password/index.ts +61 -0
- package/make-template/assets/express-api/src/api/auth/verify-email/index.ts +79 -0
- package/make-template/assets/express-api/src/api/auth/whoami/index.ts +68 -0
- package/make-template/assets/express-api/src/api/index.ts +18 -0
- package/make-template/assets/express-api/src/api/types.ts +76 -0
- package/make-template/assets/express-api/src/api/utils.ts +146 -0
- package/make-template/assets/express-api/src/database/index.ts +173 -0
- package/make-template/assets/express-api/src/email/index.ts +47 -0
- package/make-template/assets/express-api/src/env/index.ts +77 -0
- package/make-template/assets/express-api/src/errors/index.ts +128 -0
- package/make-template/assets/express-api/src/index.ts +42 -0
- package/make-template/assets/express-api/src/init/index.ts +156 -0
- package/make-template/assets/express-api/src/jwt/index.ts +105 -0
- package/make-template/assets/express-api/src/public/index.css +7 -0
- package/make-template/assets/express-api/src/public/index.html +15 -0
- package/make-template/assets/express-api/src/public/index.js +31 -0
- package/make-template/assets/express-api/src/schema/_history/index.ts +124 -0
- package/make-template/assets/express-api/src/schema/_meta/index.ts +113 -0
- package/make-template/assets/express-api/src/schema/index.ts +17 -0
- package/make-template/assets/express-api/src/schema/user/index.ts +117 -0
- package/make-template/assets/express-api/src/schema/user-email-validation-token/index.ts +20 -0
- package/make-template/assets/express-api/src/schema/user-password-renewal-token/index.ts +20 -0
- package/make-template/assets/express-api/src/schema/user-revoked-auth-token/index.ts +26 -0
- package/make-template/assets/express-api/src/tsconfig.json +16 -0
- package/make-template/assets/express-api/src/www/index.ts +43 -0
- package/make-template/index.js +2 -3
- package/make-template/index.js.map +3 -3
- package/package.json +7 -8
- package/make-template/assets/express/src/routes/index.ts +0 -7
- package/upgrade/index.js +0 -12
- package/upgrade/index.js.map +0 -7
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { Duration } from '@design-edito/tools/agnostic/time/duration'
|
|
3
|
+
import { Logs } from '@design-edito/tools/agnostic/misc/logs'
|
|
4
|
+
import * as Database from '../../../database'
|
|
5
|
+
import * as Email from '../../../email'
|
|
6
|
+
import {
|
|
7
|
+
USER_EMAIL_VALIDATION_TOKEN_LIFETIME_MINUTES,
|
|
8
|
+
EMAILER_EMAIL,
|
|
9
|
+
EMAILER_NAME,
|
|
10
|
+
ROOT_USER_ID
|
|
11
|
+
} from '../../../env'
|
|
12
|
+
import {
|
|
13
|
+
IUserEmailValidationToken,
|
|
14
|
+
UserEmailValidationTokenModel
|
|
15
|
+
} from '../../../schema/user-email-validation-token'
|
|
16
|
+
|
|
17
|
+
export async function createAndSendUserEmailValidationToken (email: string, username: string) {
|
|
18
|
+
const tokenLifetimeMinutes = parseInt(USER_EMAIL_VALIDATION_TOKEN_LIFETIME_MINUTES)
|
|
19
|
+
const tokenLifetimeMs = Duration.minutes(tokenLifetimeMinutes).toMilliseconds()
|
|
20
|
+
const tokenExpiresOnTimestamp = Date.now() + tokenLifetimeMs
|
|
21
|
+
const tokenExpiresOn = new Date(tokenExpiresOnTimestamp)
|
|
22
|
+
const tokenValue = randomUUID()
|
|
23
|
+
const tokenDocument: IUserEmailValidationToken = {
|
|
24
|
+
email,
|
|
25
|
+
value: tokenValue,
|
|
26
|
+
expiresOn: tokenExpiresOn,
|
|
27
|
+
}
|
|
28
|
+
const tokenInserted = await Database.insertOne(UserEmailValidationTokenModel, tokenDocument, { initiatorId: ROOT_USER_ID })
|
|
29
|
+
if (!tokenInserted.success) {
|
|
30
|
+
console.log(Logs.styles.error('Token not inserted'))
|
|
31
|
+
console.log(Logs.styles.regular(tokenInserted.error.message))
|
|
32
|
+
console.log(tokenInserted.error.details)
|
|
33
|
+
// [WIP] what do here ?
|
|
34
|
+
// send mail to admin ?
|
|
35
|
+
// Retry ?
|
|
36
|
+
// Send fallback mail to user something like 'request a new token' ?
|
|
37
|
+
// Do nothing and try create and send token again when trying to login ?
|
|
38
|
+
}
|
|
39
|
+
const emailBody = Email.makeUserEmailVerificationTokenBody(username, tokenValue)
|
|
40
|
+
return await Email.send(
|
|
41
|
+
EMAILER_EMAIL,
|
|
42
|
+
EMAILER_NAME,
|
|
43
|
+
email,
|
|
44
|
+
username,
|
|
45
|
+
'Validate your email',
|
|
46
|
+
emailBody)
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// import { google } from './google'
|
|
2
|
+
// import { googleCallback } from './google-callback'
|
|
3
|
+
import { login } from './login'
|
|
4
|
+
import { logout } from './logout'
|
|
5
|
+
import { refreshToken } from './refresh-token'
|
|
6
|
+
import { requestEmailVerificationToken } from './request-email-verification-token'
|
|
7
|
+
import { requestNewPassword } from './request-new-password'
|
|
8
|
+
import { signup } from './signup'
|
|
9
|
+
import { submitNewPassword } from './submit-new-password'
|
|
10
|
+
import { verifyEmail } from './verify-email'
|
|
11
|
+
import { whoami } from './whoami'
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
// google,
|
|
15
|
+
// googleCallback,
|
|
16
|
+
login,
|
|
17
|
+
logout,
|
|
18
|
+
refreshToken,
|
|
19
|
+
requestEmailVerificationToken,
|
|
20
|
+
requestNewPassword,
|
|
21
|
+
signup,
|
|
22
|
+
submitNewPassword,
|
|
23
|
+
verifyEmail,
|
|
24
|
+
whoami
|
|
25
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt'
|
|
2
|
+
import validator from 'validator'
|
|
3
|
+
import zod from 'zod'
|
|
4
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
5
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
6
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
7
|
+
import * as Database from '../../../database'
|
|
8
|
+
import { Codes, makeFailureOutcome } from '../../../errors'
|
|
9
|
+
import { ROOT_USER_ID } from '../../../env'
|
|
10
|
+
import * as Jwt from '../../../jwt'
|
|
11
|
+
import { Role, Status, LocalUserModel } from '../../../schema/user'
|
|
12
|
+
import { makeEndpoint, nullAuthenticator } from '../../utils'
|
|
13
|
+
|
|
14
|
+
export type ExpectedBody = {
|
|
15
|
+
email: string
|
|
16
|
+
password: string
|
|
17
|
+
} | {
|
|
18
|
+
username: string
|
|
19
|
+
password: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SuccessResponse = {
|
|
23
|
+
_id: string
|
|
24
|
+
username: string
|
|
25
|
+
email: string
|
|
26
|
+
role: Role
|
|
27
|
+
status: Status
|
|
28
|
+
verified: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const login = makeEndpoint<ExpectedBody, SuccessResponse>(
|
|
32
|
+
/* Authentication */
|
|
33
|
+
nullAuthenticator,
|
|
34
|
+
|
|
35
|
+
/* Validation */
|
|
36
|
+
async req => {
|
|
37
|
+
const { body } = req
|
|
38
|
+
if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
|
|
39
|
+
if ('email' in body) {
|
|
40
|
+
const withEmailSchema = zod.object({
|
|
41
|
+
email: zod.string().email('Invalid email format'),
|
|
42
|
+
password: zod.string().min(1, 'Password must be at least one character long')
|
|
43
|
+
})
|
|
44
|
+
try {
|
|
45
|
+
const withEmailValidated = withEmailSchema.parse(body)
|
|
46
|
+
return Outcome.makeSuccess(withEmailValidated)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const errStr = unknownToString(err)
|
|
49
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
50
|
+
}
|
|
51
|
+
} else if ('username' in body) {
|
|
52
|
+
const withUsernameSchema = zod.object({
|
|
53
|
+
username: zod.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.refine(input => validator.isSlug(input.toLowerCase()), {
|
|
56
|
+
message: 'Username must contain alphanumeric or non starting, trailing nor consecutive hyphens or underscores'
|
|
57
|
+
}),
|
|
58
|
+
password: zod.string().min(1, 'Password must be at least one character long')
|
|
59
|
+
})
|
|
60
|
+
try {
|
|
61
|
+
const withUsernameValidated = withUsernameSchema.parse(body)
|
|
62
|
+
return Outcome.makeSuccess(withUsernameValidated)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const errStr = unknownToString(err)
|
|
65
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Missing username or email property')
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/* Process */
|
|
73
|
+
async (body, _req, res) => {
|
|
74
|
+
const { password } = body
|
|
75
|
+
const query = 'email' in body ? { email: body.email } : { username: body.username }
|
|
76
|
+
const foundUser = await Database.findOne(LocalUserModel, query, { initiatorId: ROOT_USER_ID })
|
|
77
|
+
if (!foundUser.success) {
|
|
78
|
+
const err = foundUser.error
|
|
79
|
+
if (err.code === Codes.DB_ERROR) return foundUser
|
|
80
|
+
return makeFailureOutcome(Codes.INVALID_CREDENTIALS)
|
|
81
|
+
}
|
|
82
|
+
const passwordsMatch = await bcrypt.compare(password, foundUser.payload.password)
|
|
83
|
+
if (!passwordsMatch) return makeFailureOutcome(Codes.INVALID_CREDENTIALS)
|
|
84
|
+
const userData = foundUser.payload
|
|
85
|
+
const userId = userData._id.toString()
|
|
86
|
+
const accessToken = await Jwt.generateAccessToken(userId, 0)
|
|
87
|
+
const refreshToken = await Jwt.generateRefreshToken(userId, 0)
|
|
88
|
+
if (!accessToken.success) return accessToken // [WIP] maybe wrap this into a less verbose error?
|
|
89
|
+
if (!refreshToken.success) return refreshToken // [WIP] maybe wrap this into a less verbose error?
|
|
90
|
+
Jwt.attachAccessTokenToRes(res, accessToken.payload)
|
|
91
|
+
Jwt.attachRefreshTokenToRes(res, refreshToken.payload)
|
|
92
|
+
return Outcome.makeSuccess({
|
|
93
|
+
_id: userId,
|
|
94
|
+
username: userData.username,
|
|
95
|
+
email: userData.email,
|
|
96
|
+
role: userData.role,
|
|
97
|
+
status: userData.status,
|
|
98
|
+
verified: userData.verified
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
2
|
+
import * as Database from '../../../database'
|
|
3
|
+
import { ROOT_USER_ID } from '../../../env'
|
|
4
|
+
import { Codes, makeFailureOutcome } from '../../../errors'
|
|
5
|
+
import { BaseUserModel } from '../../../schema/user'
|
|
6
|
+
import { UserRevokedTokenModel } from '../../../schema/user-revoked-auth-token'
|
|
7
|
+
import { makeEndpoint, nullValidator } from '../../utils'
|
|
8
|
+
|
|
9
|
+
export const logout = makeEndpoint<{}, {}>(
|
|
10
|
+
/* Authentication */
|
|
11
|
+
async (_req, res) => {
|
|
12
|
+
// [WIP] this generic "user authed and exists" stuff will have to live elsewhere
|
|
13
|
+
const { accessTokenPayload, accessTokenSigned } = res.locals
|
|
14
|
+
if (accessTokenPayload === undefined) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'No access token provided')
|
|
15
|
+
const { userId, exp } = accessTokenPayload
|
|
16
|
+
const now = Date.now()
|
|
17
|
+
const tokenHasExpired = now >= exp * 1000
|
|
18
|
+
if (tokenHasExpired) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Access token has expired')
|
|
19
|
+
const foundRevokedToken = await Database.findOne(UserRevokedTokenModel, { value: accessTokenSigned }, { initiatorId: ROOT_USER_ID })
|
|
20
|
+
if (foundRevokedToken.success) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Access token has been revoked')
|
|
21
|
+
const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
|
|
22
|
+
if (!foundUser.success) return makeFailureOutcome(Codes.USER_DOES_NOT_EXIST, `Could not find a user with id ${userId}`)
|
|
23
|
+
return Outcome.makeSuccess(true)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/* Validation */
|
|
27
|
+
nullValidator,
|
|
28
|
+
|
|
29
|
+
/* Process */
|
|
30
|
+
async (_body, _req, res) => {
|
|
31
|
+
const { accessTokenSigned, refreshTokenSigned } = res.locals
|
|
32
|
+
const now = new Date()
|
|
33
|
+
const toRevoke = [accessTokenSigned, refreshTokenSigned]
|
|
34
|
+
.filter(token => token !== undefined)
|
|
35
|
+
.map(token => ({ value: token, revokedOn: now }))
|
|
36
|
+
if (toRevoke.length === 0) return Outcome.makeSuccess({})
|
|
37
|
+
const blacklisted = await Database.insertMany(
|
|
38
|
+
UserRevokedTokenModel,
|
|
39
|
+
toRevoke,
|
|
40
|
+
{ initiatorId: ROOT_USER_ID }
|
|
41
|
+
)
|
|
42
|
+
if (!blacklisted.success) return blacklisted
|
|
43
|
+
return Outcome.makeSuccess({})
|
|
44
|
+
}
|
|
45
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken'
|
|
2
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
3
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
4
|
+
import * as Database from '../../../database'
|
|
5
|
+
import { JWT_SECRET, REFRESH_TOKEN_MAX_REFRESH_COUNT, ROOT_USER_ID } from '../../../env'
|
|
6
|
+
import { Codes, makeFailureOutcome } from '../../../errors'
|
|
7
|
+
import * as Jwt from '../../../jwt'
|
|
8
|
+
import { BaseUserModel } from '../../../schema/user'
|
|
9
|
+
import { makeEndpoint, nullValidator } from '../../utils'
|
|
10
|
+
import { UserRevokedTokenModel } from '../../../schema/user-revoked-auth-token'
|
|
11
|
+
|
|
12
|
+
export const refreshToken = makeEndpoint<{}, {}>(
|
|
13
|
+
|
|
14
|
+
/* Authentication */
|
|
15
|
+
async (req, res) => {
|
|
16
|
+
const refreshToken = req.cookies?.refreshToken
|
|
17
|
+
const tokenIsString = typeof refreshToken === 'string'
|
|
18
|
+
const foundRevokedToken = await Database.findOne(UserRevokedTokenModel, { value: refreshToken }, { initiatorId: ROOT_USER_ID })
|
|
19
|
+
if (foundRevokedToken.success) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has been revoked')
|
|
20
|
+
if (!tokenIsString) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'No refresh token provided')
|
|
21
|
+
try {
|
|
22
|
+
const decoded = jwt.verify(refreshToken, JWT_SECRET)
|
|
23
|
+
if (!Jwt.isValidPayload(decoded)) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Malformed refresh token')
|
|
24
|
+
const { userId, exp, refreshCount } = decoded
|
|
25
|
+
const now = Math.floor(Date.now() / 1000)
|
|
26
|
+
const tokenHasExpired = now >= exp
|
|
27
|
+
if (tokenHasExpired) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has expired')
|
|
28
|
+
if (refreshCount >= REFRESH_TOKEN_MAX_REFRESH_COUNT) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has reached maximum refresh count')
|
|
29
|
+
const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
|
|
30
|
+
if (!foundUser.success) return makeFailureOutcome(Codes.USER_DOES_NOT_EXIST, `Could not find a user with id ${userId}`)
|
|
31
|
+
res.locals.refreshTokenSigned = refreshToken
|
|
32
|
+
res.locals.refreshTokenPayload = decoded
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, `Bad refresh token: ${unknownToString(err)}`)
|
|
35
|
+
}
|
|
36
|
+
return Outcome.makeSuccess(true)
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/* Request body validation (none) */
|
|
40
|
+
nullValidator,
|
|
41
|
+
|
|
42
|
+
/* Process request */
|
|
43
|
+
async (_body, _req, res) => {
|
|
44
|
+
if (res.locals.refreshTokenPayload === undefined) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has expired')
|
|
45
|
+
const { userId, refreshCount } = res.locals.refreshTokenPayload
|
|
46
|
+
const newAccessToken = await Jwt.generateAccessToken(userId, 0)
|
|
47
|
+
const newRefreshToken = await Jwt.generateRefreshToken(userId, refreshCount + 1)
|
|
48
|
+
if (!newAccessToken.success) return newAccessToken // [WIP] maybe wrap this into a less verbose error?
|
|
49
|
+
if (!newRefreshToken.success) return newRefreshToken // [WIP] maybe wrap this into a less verbose error?
|
|
50
|
+
Jwt.attachAccessTokenToRes(res, newAccessToken.payload)
|
|
51
|
+
Jwt.attachRefreshTokenToRes(res, newRefreshToken.payload)
|
|
52
|
+
return Outcome.makeSuccess({})
|
|
53
|
+
}
|
|
54
|
+
)
|
package/make-template/assets/express-api/src/api/auth/request-email-verification-token/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import zod from 'zod'
|
|
2
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
3
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
4
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
5
|
+
import * as Database from '../../../database'
|
|
6
|
+
import { ROOT_USER_ID } from '../../../env'
|
|
7
|
+
import { Codes, makeFailureOutcome } from '../../../errors'
|
|
8
|
+
import { LocalUserModel } from '../../../schema/user'
|
|
9
|
+
import { UserEmailValidationTokenModel } from '../../../schema/user-email-validation-token'
|
|
10
|
+
import { makeEndpoint, nullAuthenticator } from '../../utils'
|
|
11
|
+
import { createAndSendUserEmailValidationToken } from '../_utils'
|
|
12
|
+
|
|
13
|
+
export type ExpectedBody = { email: string }
|
|
14
|
+
|
|
15
|
+
export const requestEmailVerificationToken = makeEndpoint<ExpectedBody, {}>(
|
|
16
|
+
/* Auth */
|
|
17
|
+
nullAuthenticator,
|
|
18
|
+
|
|
19
|
+
/* Validation */
|
|
20
|
+
async req => {
|
|
21
|
+
const { body } = req
|
|
22
|
+
if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
|
|
23
|
+
const validationSchema = zod.object({ email: zod.string().email('Invalid email format') })
|
|
24
|
+
try {
|
|
25
|
+
const validated = validationSchema.parse(body)
|
|
26
|
+
return Outcome.makeSuccess(validated)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const errStr = unknownToString(err)
|
|
29
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
/* Process */
|
|
34
|
+
async body => {
|
|
35
|
+
const { email } = body
|
|
36
|
+
const userLookup = await Database.findOne(LocalUserModel, { email }, { initiatorId: ROOT_USER_ID })
|
|
37
|
+
if (!userLookup.success) return makeFailureOutcome(Codes.USER_EMAIL_DOES_NOT_EXIST, email)
|
|
38
|
+
const tokensDeletion = await Database.deleteMany(UserEmailValidationTokenModel, { email }, { initiatorId: ROOT_USER_ID })
|
|
39
|
+
if (!tokensDeletion.success) { /* [WIP] what do ? */ }
|
|
40
|
+
const userData = userLookup.payload
|
|
41
|
+
if (userData.verified === true) return makeFailureOutcome(Codes.USER_EMAIL_ALREADY_VERIFIED, email) // [WIP] maybe this discloses user data?
|
|
42
|
+
await createAndSendUserEmailValidationToken(email, userData.username)
|
|
43
|
+
return Outcome.makeSuccess({})
|
|
44
|
+
}
|
|
45
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import zod from 'zod'
|
|
3
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
4
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
5
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
6
|
+
import { Duration } from '@design-edito/tools/agnostic/time/duration'
|
|
7
|
+
import * as Database from '../../../database'
|
|
8
|
+
import * as Email from '../../../email'
|
|
9
|
+
import { EMAILER_EMAIL, EMAILER_NAME, ROOT_USER_ID } from '../../../env'
|
|
10
|
+
import { makeFailureOutcome, Codes } from '../../../errors'
|
|
11
|
+
import { LocalUserModel } from '../../../schema/user'
|
|
12
|
+
import { UserPasswordRenewalTokenModel } from '../../../schema/user-password-renewal-token'
|
|
13
|
+
import { makeEndpoint, nullAuthenticator } from '../../utils'
|
|
14
|
+
|
|
15
|
+
export type ExpectedBody = {
|
|
16
|
+
email: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const requestNewPassword = makeEndpoint<ExpectedBody, {}>(
|
|
20
|
+
/* Authentication */
|
|
21
|
+
nullAuthenticator,
|
|
22
|
+
|
|
23
|
+
/* Validation */
|
|
24
|
+
async req => {
|
|
25
|
+
const { body } = req
|
|
26
|
+
if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
|
|
27
|
+
const validationSchema = zod.object({ email: zod.string().email('Invalid email format') })
|
|
28
|
+
try {
|
|
29
|
+
const validated = validationSchema.parse(body)
|
|
30
|
+
return Outcome.makeSuccess(validated)
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const errStr = unknownToString(err)
|
|
33
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/* Process */
|
|
38
|
+
async body => {
|
|
39
|
+
const foundUser = await Database.findOne(LocalUserModel, { email: body.email }, { initiatorId: ROOT_USER_ID })
|
|
40
|
+
if (!foundUser.success) return makeFailureOutcome(Codes.USER_EMAIL_DOES_NOT_EXIST, body.email)
|
|
41
|
+
await Database.deleteMany(UserPasswordRenewalTokenModel, { email: body.email }, { initiatorId: ROOT_USER_ID })
|
|
42
|
+
const token = randomUUID()
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
const tokenLifetimeMs = Duration.hours(1).toMilliseconds()
|
|
45
|
+
const expiresOn = new Date(now + tokenLifetimeMs)
|
|
46
|
+
const tokenSaved = await Database.insertOne(
|
|
47
|
+
UserPasswordRenewalTokenModel,
|
|
48
|
+
{ value: token, email: body.email, expiresOn },
|
|
49
|
+
{ initiatorId: ROOT_USER_ID }
|
|
50
|
+
)
|
|
51
|
+
if (!tokenSaved.success) return tokenSaved
|
|
52
|
+
const emailBody = Email.makeUserPasswordRenewalTokenBody(body.email, token)
|
|
53
|
+
await Email.send(
|
|
54
|
+
EMAILER_EMAIL,
|
|
55
|
+
EMAILER_NAME,
|
|
56
|
+
foundUser.payload.email,
|
|
57
|
+
foundUser.payload.username,
|
|
58
|
+
'New password request',
|
|
59
|
+
emailBody)
|
|
60
|
+
return Outcome.makeSuccess({})
|
|
61
|
+
}
|
|
62
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt'
|
|
2
|
+
import validator from 'validator'
|
|
3
|
+
import zod from 'zod'
|
|
4
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
5
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
6
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
7
|
+
import { Logs } from '@design-edito/tools/agnostic/misc/logs'
|
|
8
|
+
import * as Database from '../../../database'
|
|
9
|
+
import { ROOT_USER_ID } from '../../../env'
|
|
10
|
+
import { Codes, makeFailureOutcome } from '../../../errors'
|
|
11
|
+
import { BaseUserModel, LocalUserModel, GoogleUserModel, Role, Status } from '../../../schema/user'
|
|
12
|
+
import { makeEndpoint, nullAuthenticator } from '../../utils'
|
|
13
|
+
import { createAndSendUserEmailValidationToken } from '../_utils'
|
|
14
|
+
|
|
15
|
+
export type ExpectedBody = {
|
|
16
|
+
username: string
|
|
17
|
+
email: string
|
|
18
|
+
password: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SuccessResponse = {
|
|
22
|
+
_id: string
|
|
23
|
+
username: string
|
|
24
|
+
email: string
|
|
25
|
+
role: Role
|
|
26
|
+
status: Status
|
|
27
|
+
verified: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const signup = makeEndpoint<ExpectedBody, SuccessResponse>(
|
|
31
|
+
/* Authentication (none) */
|
|
32
|
+
nullAuthenticator,
|
|
33
|
+
|
|
34
|
+
/* Body validation */
|
|
35
|
+
async req => {
|
|
36
|
+
const { body } = req
|
|
37
|
+
if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
|
|
38
|
+
const validationSchema = zod.object({
|
|
39
|
+
username: zod.string()
|
|
40
|
+
.min(1)
|
|
41
|
+
.refine(input => validator.isSlug(input.toLowerCase()), {
|
|
42
|
+
message: 'Username must contain alphanumeric or non starting, trailing nor consecutive hyphens or underscores'
|
|
43
|
+
}),
|
|
44
|
+
email: zod.string().email('Invalid email format'),
|
|
45
|
+
password: zod.string().min(1, 'Password must be at least one character long')
|
|
46
|
+
})
|
|
47
|
+
try {
|
|
48
|
+
const validated = validationSchema.parse(body)
|
|
49
|
+
return Outcome.makeSuccess(validated)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const errStr = unknownToString(err)
|
|
52
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/* Process request */
|
|
57
|
+
async body => {
|
|
58
|
+
const { username, email, password } = body
|
|
59
|
+
const rootQueryContext: Database.OperationContext = { initiatorId: ROOT_USER_ID }
|
|
60
|
+
const foundUsername = await Database.findOne(BaseUserModel, { username }, rootQueryContext)
|
|
61
|
+
if (foundUsername.success) return makeFailureOutcome(Codes.USERNAME_ALREADY_TAKEN, username)
|
|
62
|
+
const foundEmail = await Database.findOne(GoogleUserModel, { email }, rootQueryContext)
|
|
63
|
+
if (foundEmail.success) return makeFailureOutcome(Codes.EMAIL_ADDRESS_ALREADY_TAKEN, email)
|
|
64
|
+
const inserted = await Database.insertOne(
|
|
65
|
+
LocalUserModel,
|
|
66
|
+
{
|
|
67
|
+
username,
|
|
68
|
+
role: Role.USER,
|
|
69
|
+
status: Status.ACTIVE,
|
|
70
|
+
email,
|
|
71
|
+
password: await bcrypt.hash(password, 10),
|
|
72
|
+
verified: false
|
|
73
|
+
},
|
|
74
|
+
rootQueryContext
|
|
75
|
+
)
|
|
76
|
+
if (!inserted.success) return inserted
|
|
77
|
+
// Create validation token, store it, send email
|
|
78
|
+
const sent = await createAndSendUserEmailValidationToken(email, username)
|
|
79
|
+
const strippedUserData: SuccessResponse = {
|
|
80
|
+
_id: inserted.payload._id.toString(),
|
|
81
|
+
username: inserted.payload.username,
|
|
82
|
+
email: inserted.payload.email,
|
|
83
|
+
role: inserted.payload.role,
|
|
84
|
+
status: inserted.payload.status,
|
|
85
|
+
verified: inserted.payload.verified
|
|
86
|
+
}
|
|
87
|
+
if (sent.success) return Outcome.makeSuccess(strippedUserData)
|
|
88
|
+
else {
|
|
89
|
+
console.log(Logs.styles.error('Email not sent'))
|
|
90
|
+
console.log(Logs.styles.regular(unknownToString(sent.error)))
|
|
91
|
+
// [WIP] what do here ?
|
|
92
|
+
// send mail to admin ?
|
|
93
|
+
// Retry ?
|
|
94
|
+
// Send fallback mail to user something like "request a new token" ?
|
|
95
|
+
// Do nothing and try create and send token again when trying to login ?
|
|
96
|
+
return Outcome.makeSuccess(strippedUserData)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt'
|
|
2
|
+
import zod from 'zod'
|
|
3
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
4
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
5
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
6
|
+
import * as Database from '../../../database'
|
|
7
|
+
import { ROOT_USER_ID } from '../../../env'
|
|
8
|
+
import { makeFailureOutcome, Codes } from '../../../errors'
|
|
9
|
+
import { LocalUserModel } from '../../../schema/user'
|
|
10
|
+
import { UserPasswordRenewalTokenModel } from '../../../schema/user-password-renewal-token'
|
|
11
|
+
import { makeEndpoint, nullAuthenticator } from '../../utils'
|
|
12
|
+
|
|
13
|
+
export type ExpectedBody = {
|
|
14
|
+
email: string
|
|
15
|
+
token: string
|
|
16
|
+
password: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const submitNewPassword = makeEndpoint<ExpectedBody, {}>(
|
|
20
|
+
nullAuthenticator,
|
|
21
|
+
|
|
22
|
+
async req => {
|
|
23
|
+
const { body } = req
|
|
24
|
+
if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
|
|
25
|
+
const validationSchema = zod.object({
|
|
26
|
+
email: zod.string().email('Invalid email format'),
|
|
27
|
+
token: zod.string(),
|
|
28
|
+
password: zod.string().min(1, 'Password must be at least one character long')
|
|
29
|
+
})
|
|
30
|
+
try {
|
|
31
|
+
const validated = validationSchema.parse(body)
|
|
32
|
+
return Outcome.makeSuccess(validated)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const errStr = unknownToString(err)
|
|
35
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async body => {
|
|
40
|
+
const deletedToken = await Database.deleteOne(
|
|
41
|
+
UserPasswordRenewalTokenModel,
|
|
42
|
+
{ value: body.token, email: body.email, expiresOn: { $gte: Date.now() } },
|
|
43
|
+
{ initiatorId: ROOT_USER_ID }
|
|
44
|
+
)
|
|
45
|
+
if (!deletedToken.success) return makeFailureOutcome(Codes.USER_PASSWORD_RENEWAL_TOKEN_DOES_NOT_EXIST, body.email, body.token)
|
|
46
|
+
const foundUser = await Database.findOne(
|
|
47
|
+
LocalUserModel,
|
|
48
|
+
{ email: body.email },
|
|
49
|
+
{ initiatorId: ROOT_USER_ID }
|
|
50
|
+
)
|
|
51
|
+
if (!foundUser.success) return makeFailureOutcome(Codes.USER_EMAIL_DOES_NOT_EXIST, body.email)
|
|
52
|
+
const updatedUser = await Database.updateOne(
|
|
53
|
+
LocalUserModel,
|
|
54
|
+
{ _id: foundUser.payload._id },
|
|
55
|
+
{ $set: { password: await bcrypt.hash(body.password, 10) } },
|
|
56
|
+
{ initiatorId: ROOT_USER_ID }
|
|
57
|
+
)
|
|
58
|
+
if (!updatedUser.success) return updatedUser
|
|
59
|
+
return Outcome.makeSuccess({})
|
|
60
|
+
}
|
|
61
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import zod from 'zod'
|
|
2
|
+
import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
|
|
3
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
|
|
4
|
+
import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
|
|
5
|
+
import * as Database from '../../../database'
|
|
6
|
+
import { ROOT_USER_ID } from '../../../env'
|
|
7
|
+
import { Codes, makeFailureOutcome } from '../../../errors'
|
|
8
|
+
import { UserEmailValidationTokenModel } from '../../../schema/user-email-validation-token'
|
|
9
|
+
import { LocalUserModel, Role, Status } from '../../../schema/user'
|
|
10
|
+
import { makeEndpoint, nullAuthenticator } from '../../utils'
|
|
11
|
+
|
|
12
|
+
export type ExpectedBody = {
|
|
13
|
+
email: string
|
|
14
|
+
token: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SuccessResponse = {
|
|
18
|
+
_id: string
|
|
19
|
+
username: string
|
|
20
|
+
email: string
|
|
21
|
+
role: Role
|
|
22
|
+
status: Status
|
|
23
|
+
verified: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const verifyEmail = makeEndpoint<ExpectedBody, SuccessResponse>(
|
|
27
|
+
/* Auth */
|
|
28
|
+
nullAuthenticator,
|
|
29
|
+
|
|
30
|
+
/* Validation */
|
|
31
|
+
async req => {
|
|
32
|
+
const { body } = req
|
|
33
|
+
if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
|
|
34
|
+
const validationSchema = zod.object({
|
|
35
|
+
email: zod.string().email('Invalid email format'),
|
|
36
|
+
token: zod.string()
|
|
37
|
+
})
|
|
38
|
+
try {
|
|
39
|
+
const validated = validationSchema.parse(body)
|
|
40
|
+
return Outcome.makeSuccess(validated)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const errStr = unknownToString(err)
|
|
43
|
+
return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/* Process */
|
|
48
|
+
async body => {
|
|
49
|
+
const { email, token } = body
|
|
50
|
+
if (token === undefined) return makeFailureOutcome(Codes.USER_EMAIL_VERIFICATION_TOKEN_NOT_PROVIDED)
|
|
51
|
+
const now = new Date()
|
|
52
|
+
const rootQueryContext = { initiatorId: ROOT_USER_ID }
|
|
53
|
+
const tokenDeletion = await Database.deleteOne(
|
|
54
|
+
UserEmailValidationTokenModel,
|
|
55
|
+
{ email, value: token, expiresOn: { $gte: now } },
|
|
56
|
+
rootQueryContext
|
|
57
|
+
)
|
|
58
|
+
if (!tokenDeletion.success) return makeFailureOutcome(Codes.USER_EMAIL_VERIFICATION_TOKEN_DOES_NOT_EXIST)
|
|
59
|
+
const userUpdation = await Database.updateOne(
|
|
60
|
+
LocalUserModel,
|
|
61
|
+
{ email },
|
|
62
|
+
{ $set: { verified: true } },
|
|
63
|
+
rootQueryContext
|
|
64
|
+
)
|
|
65
|
+
if (!userUpdation.success) return makeFailureOutcome(
|
|
66
|
+
Codes.USER_EMAIL_VERIFICATION_PROCESS_FAILED,
|
|
67
|
+
userUpdation.error.message
|
|
68
|
+
)
|
|
69
|
+
const updatedUser = userUpdation.payload
|
|
70
|
+
return Outcome.makeSuccess({
|
|
71
|
+
_id: updatedUser._id.toString(),
|
|
72
|
+
username: updatedUser.username,
|
|
73
|
+
email: updatedUser.email,
|
|
74
|
+
role: updatedUser.role,
|
|
75
|
+
status: updatedUser.status,
|
|
76
|
+
verified: updatedUser.verified
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
)
|