ajout app
This commit is contained in:
1
Voltaserve/idp/.dockerignore
Normal file
1
Voltaserve/idp/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
10
Voltaserve/idp/.editorconfig
Normal file
10
Voltaserve/idp/.editorconfig
Normal file
@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,json,yml}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
24
Voltaserve/idp/.env
Normal file
24
Voltaserve/idp/.env
Normal file
@ -0,0 +1,24 @@
|
||||
PORT=7000
|
||||
|
||||
# URLs
|
||||
PUBLIC_UI_URL="http://localhost:3000"
|
||||
POSTGRES_URL="postgresql://voltaserve:voltaserve@127.0.0.1:5432/voltaserve"
|
||||
|
||||
# Token
|
||||
TOKEN_JWT_SIGNING_KEY="586cozl1x9m6zmu4fg8iwi6ajazguehcm9qdfgd5ndo2pc3pcn"
|
||||
TOKEN_AUDIENCE="localhost"
|
||||
TOKEN_ISSUER="localhost"
|
||||
TOKEN_ACCESS_TOKEN_LIFETIME=86400
|
||||
TOKEN_REFRESH_TOKEN_LIFETIME=2592000
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS="http://localhost:3000"
|
||||
|
||||
# Search
|
||||
SEARCH_URL="http://127.0.0.1:7700"
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST="127.0.0.1"
|
||||
SMTP_PORT=1025
|
||||
SMTP_SENDER_ADDRESS="no-reply@localhost"
|
||||
SMTP_SENDER_NAME="Voltaserve"
|
1
Voltaserve/idp/.gitattributes
vendored
Normal file
1
Voltaserve/idp/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
templates/** linguist-detectable=false
|
2
Voltaserve/idp/.gitignore
vendored
Normal file
2
Voltaserve/idp/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env.local
|
2
Voltaserve/idp/.prettierignore
Normal file
2
Voltaserve/idp/.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
dist
|
||||
templates/**/*.hbs
|
12
Voltaserve/idp/.prettierrc.json
Normal file
12
Voltaserve/idp/.prettierrc.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"quoteProps": "preserve",
|
||||
"importOrder": [
|
||||
"^@/infra/env$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": false
|
||||
}
|
3
Voltaserve/idp/.vscode/extensions.json
vendored
Normal file
3
Voltaserve/idp/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
19
Voltaserve/idp/Dockerfile
Normal file
19
Voltaserve/idp/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM registry.suse.com/bci/nodejs:18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
COPY .env .
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY tsconfig.json .
|
||||
|
||||
RUN npm install -g corepack
|
||||
RUN corepack enable
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
ENTRYPOINT ["pnpm", "run", "start"]
|
||||
|
||||
EXPOSE 7000
|
47
Voltaserve/idp/README.md
Normal file
47
Voltaserve/idp/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Voltaserve Identity Provider
|
||||
|
||||
## Getting Started
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```shell
|
||||
bun i
|
||||
```
|
||||
|
||||
Run for development:
|
||||
|
||||
```shell
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Run for production:
|
||||
|
||||
```shell
|
||||
bun run start
|
||||
```
|
||||
|
||||
Build Docker image:
|
||||
|
||||
```shell
|
||||
docker build -t voltaserve/idp .
|
||||
```
|
||||
|
||||
## Generate and Publish Documentation
|
||||
|
||||
Generate `swagger.json`:
|
||||
|
||||
```shell
|
||||
pnpm swagger-autogen && mv ./swagger.json ./docs
|
||||
```
|
||||
|
||||
Preview (will be served at [http://127.0.0.1:7777](http://127.0.0.1:7777)):
|
||||
|
||||
```shell
|
||||
npx @redocly/cli preview-docs --port 7777 ./docs/swagger.json
|
||||
```
|
||||
|
||||
Generate the final static HTML documentation:
|
||||
|
||||
```shell
|
||||
npx @redocly/cli build-docs ./docs/swagger.json --output ./docs/index.html
|
||||
```
|
BIN
Voltaserve/idp/bun.lockb
Normal file
BIN
Voltaserve/idp/bun.lockb
Normal file
Binary file not shown.
288
Voltaserve/idp/docs/index.html
Normal file
288
Voltaserve/idp/docs/index.html
Normal file
File diff suppressed because one or more lines are too long
176
Voltaserve/idp/docs/swagger.json
Normal file
176
Voltaserve/idp/docs/swagger.json
Normal file
@ -0,0 +1,176 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "Voltaserve Identity Provider",
|
||||
"version": "1.0.0",
|
||||
"description": ""
|
||||
},
|
||||
"host": "localhost:3000",
|
||||
"basePath": "/",
|
||||
"schemes": [
|
||||
"http"
|
||||
],
|
||||
"paths": {
|
||||
"/v1/health": {
|
||||
"get": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/": {
|
||||
"get": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/update_full_name": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/update_email_request": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/update_email_confirmation": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/update_password": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/update_picture": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/user/delete_picture": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/accounts/": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/accounts/reset_password": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/accounts/confirm_email": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/accounts/send_reset_password_email": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/token/": {
|
||||
"post": {
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
13
Voltaserve/idp/eslint.config.cjs
Normal file
13
Voltaserve/idp/eslint.config.cjs
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
files: ['./src/**/*.ts'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
62
Voltaserve/idp/package.json
Normal file
62
Voltaserve/idp/package.json
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "voltaserve-idp",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun src/app.ts",
|
||||
"dev": "bun --watch src/app.ts",
|
||||
"tsc": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"swagger-autogen": "node ./swagger.js",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "1.20.2",
|
||||
"camelize": "1.0.1",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.4.5",
|
||||
"express": "4.19.2",
|
||||
"express-validator": "7.0.1",
|
||||
"handlebars": "4.7.8",
|
||||
"hashids": "2.3.0",
|
||||
"jose": "5.2.4",
|
||||
"js-yaml": "4.1.0",
|
||||
"meilisearch": "0.38.0",
|
||||
"mime-types": "2.1.35",
|
||||
"minio": "7.1.3",
|
||||
"morgan": "1.10.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"nodemailer": "6.9.13",
|
||||
"passport": "0.7.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"pg": "8.11.5",
|
||||
"uuid": "9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "4.3.0",
|
||||
"@types/body-parser": "1.19.5",
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/minio": "7.1.1",
|
||||
"@types/morgan": "1.9.9",
|
||||
"@types/multer": "1.4.11",
|
||||
"@types/node": "20.12.7",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/passport": "1.0.16",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/pg": "8.11.5",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "7.7.0",
|
||||
"@typescript-eslint/parser": "7.7.0",
|
||||
"eslint": "9.0.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"prettier": "3.2.5",
|
||||
"swagger-autogen": "2.23.7",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
91
Voltaserve/idp/src/account/router.ts
Normal file
91
Voltaserve/idp/src/account/router.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import { parseValidationError } from '@/infra/error'
|
||||
import {
|
||||
confirmEmail,
|
||||
createUser,
|
||||
resetPassword,
|
||||
sendResetPasswordEmail,
|
||||
AccountConfirmEmailOptions,
|
||||
AccountCreateOptions,
|
||||
AccountResetPasswordOptions,
|
||||
AccountSendResetPasswordEmailOptions,
|
||||
} from './service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
body('password').isStrongPassword().isLength({ max: 10000 }),
|
||||
body('fullName').isString().notEmpty().trim().escape().isLength({ max: 255 }),
|
||||
body('picture').optional().isBase64().isByteLength({ max: 3000000 }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
res.json(await createUser(req.body as AccountCreateOptions))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/reset_password',
|
||||
body('token').isString().notEmpty().trim(),
|
||||
body('newPassword').isStrongPassword(),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
await resetPassword(req.body as AccountResetPasswordOptions)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/confirm_email',
|
||||
body('token').isString().notEmpty().trim(),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
await confirmEmail(req.body as AccountConfirmEmailOptions)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/send_reset_password_email',
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
res.json(
|
||||
await sendResetPasswordEmail(
|
||||
req.body as AccountSendResetPasswordEmailOptions
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
118
Voltaserve/idp/src/account/service.ts
Normal file
118
Voltaserve/idp/src/account/service.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { getConfig } from '@/config/config'
|
||||
import { newDateTime } from '@/infra/date-time'
|
||||
import { ErrorCode, newError } from '@/infra/error'
|
||||
import { newHashId, newHyphenlessUuid } from '@/infra/id'
|
||||
import { sendTemplateMail } from '@/infra/mail'
|
||||
import { hashPassword } from '@/infra/password'
|
||||
import search, { USER_SEARCH_INDEX } from '@/infra/search'
|
||||
import userRepo, { User } from '@/user/repo'
|
||||
import { mapEntity, UserDTO } from '@/user/service'
|
||||
|
||||
export type AccountCreateOptions = {
|
||||
email: string
|
||||
password: string
|
||||
fullName: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
export type AccountResetPasswordOptions = {
|
||||
token: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export type AccountConfirmEmailOptions = {
|
||||
token: string
|
||||
}
|
||||
|
||||
export type AccountSendResetPasswordEmailOptions = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
options: AccountCreateOptions
|
||||
): Promise<UserDTO> {
|
||||
const id = newHashId()
|
||||
if (!(await userRepo.isUsernameAvailable(options.email))) {
|
||||
throw newError({ code: ErrorCode.UsernameUnavailable })
|
||||
}
|
||||
try {
|
||||
const emailConfirmationToken = newHyphenlessUuid()
|
||||
const user = await userRepo.insert({
|
||||
id,
|
||||
username: options.email,
|
||||
email: options.email,
|
||||
fullName: options.fullName,
|
||||
picture: options.picture,
|
||||
passwordHash: hashPassword(options.password),
|
||||
emailConfirmationToken,
|
||||
createTime: newDateTime(),
|
||||
})
|
||||
await search.index(USER_SEARCH_INDEX).addDocuments([
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
isEmailConfirmed: user.isEmailConfirmed,
|
||||
createTime: user.createTime,
|
||||
},
|
||||
])
|
||||
await sendTemplateMail('email-confirmation', options.email, {
|
||||
'UI_URL': getConfig().publicUIURL,
|
||||
'TOKEN': emailConfirmationToken,
|
||||
})
|
||||
return mapEntity(user)
|
||||
} catch (error) {
|
||||
await userRepo.delete(id)
|
||||
await search.index(USER_SEARCH_INDEX).deleteDocuments([id])
|
||||
throw newError({ code: ErrorCode.InternalServerError, error })
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(options: AccountResetPasswordOptions) {
|
||||
const user = await userRepo.findByResetPasswordToken(options.token)
|
||||
await userRepo.update({
|
||||
id: user.id,
|
||||
passwordHash: hashPassword(options.newPassword),
|
||||
})
|
||||
}
|
||||
|
||||
export async function confirmEmail(options: AccountConfirmEmailOptions) {
|
||||
let user = await userRepo.findByEmailConfirmationToken(options.token)
|
||||
user = await userRepo.update({
|
||||
id: user.id,
|
||||
isEmailConfirmed: true,
|
||||
emailConfirmationToken: null,
|
||||
})
|
||||
await search.index(USER_SEARCH_INDEX).updateDocuments([
|
||||
{
|
||||
...user,
|
||||
isEmailConfirmed: user.isEmailConfirmed,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export async function sendResetPasswordEmail(
|
||||
options: AccountSendResetPasswordEmailOptions
|
||||
) {
|
||||
let user: User
|
||||
try {
|
||||
user = await userRepo.findByEmail(options.email)
|
||||
user = await userRepo.update({
|
||||
id: user.id,
|
||||
resetPasswordToken: newHyphenlessUuid(),
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await sendTemplateMail('reset-password', user.email, {
|
||||
'UI_URL': getConfig().publicUIURL,
|
||||
'TOKEN': user.resetPasswordToken,
|
||||
})
|
||||
} catch (error) {
|
||||
const { id } = await userRepo.findByEmail(options.email)
|
||||
await userRepo.update({ id, resetPasswordToken: null })
|
||||
throw newError({ code: ErrorCode.InternalServerError, error })
|
||||
}
|
||||
}
|
60
Voltaserve/idp/src/app.ts
Normal file
60
Voltaserve/idp/src/app.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import '@/infra/env'
|
||||
import bodyParser from 'body-parser'
|
||||
import cors from 'cors'
|
||||
import express, { Request, Response } from 'express'
|
||||
import logger from 'morgan'
|
||||
import passport from 'passport'
|
||||
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'
|
||||
import accountRouter from '@/account/router'
|
||||
import { getConfig } from '@/config/config'
|
||||
import { errorHandler } from '@/infra/error'
|
||||
import tokenRouter from '@/token/router'
|
||||
import userRepo from '@/user/repo'
|
||||
import userRouter from '@/user/router'
|
||||
import { client as postgres } from './infra/postgres'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors())
|
||||
app.use(logger('dev'))
|
||||
app.use(express.json({ limit: '3mb' }))
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(bodyParser.json())
|
||||
|
||||
const { jwtSigningKey: secretOrKey, issuer, audience } = getConfig().token
|
||||
passport.use(
|
||||
new JwtStrategy(
|
||||
{
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey,
|
||||
issuer,
|
||||
audience,
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const user = await userRepo.findByID(payload.sub)
|
||||
return done(null, user)
|
||||
} catch {
|
||||
return done(null, false)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
app.get('/v1/health', (_: Request, res: Response) => {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
app.use('/v1/user', userRouter)
|
||||
app.use('/v1/accounts', accountRouter)
|
||||
app.use('/v1/token', tokenRouter)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
const port = getConfig().port
|
||||
|
||||
postgres.connect().then(() => {
|
||||
app.listen(port, () => {
|
||||
console.log(`Listening on port ${port}`)
|
||||
})
|
||||
})
|
62
Voltaserve/idp/src/config/config.ts
Normal file
62
Voltaserve/idp/src/config/config.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Config } from './types'
|
||||
|
||||
let config: Config
|
||||
|
||||
export function getConfig(): Config {
|
||||
if (!config) {
|
||||
config = new Config()
|
||||
config.port = parseInt(process.env.PORT)
|
||||
readURLs(config)
|
||||
readToken(config)
|
||||
readCORS(config)
|
||||
readSearch(config)
|
||||
readSMTP(config)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function readURLs(config: Config) {
|
||||
config.publicUIURL = process.env.PUBLIC_UI_URL
|
||||
config.databaseURL = process.env.POSTGRES_URL
|
||||
}
|
||||
|
||||
export function readToken(config: Config) {
|
||||
config.token.jwtSigningKey = process.env.TOKEN_JWT_SIGNING_KEY
|
||||
config.token.audience = process.env.TOKEN_AUDIENCE
|
||||
config.token.issuer = process.env.TOKEN_ISSUER
|
||||
if (process.env.TOKEN_ACCESS_TOKEN_LIFETIME) {
|
||||
config.token.accessTokenLifetime = parseInt(
|
||||
process.env.TOKEN_ACCESS_TOKEN_LIFETIME
|
||||
)
|
||||
}
|
||||
if (process.env.TOKEN_REFRESH_TOKEN_LIFETIME) {
|
||||
config.token.refreshTokenLifetime = parseInt(
|
||||
process.env.TOKEN_REFRESH_TOKEN_LIFETIME
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function readCORS(config: Config) {
|
||||
if (process.env.CORS_ORIGINS) {
|
||||
config.corsOrigins = process.env.CORS_ORIGINS.split(',')
|
||||
config.corsOrigins.forEach((e) => e.trim())
|
||||
}
|
||||
}
|
||||
|
||||
export function readSearch(config: Config) {
|
||||
config.search.url = process.env.SEARCH_URL
|
||||
}
|
||||
|
||||
export function readSMTP(config: Config) {
|
||||
config.smtp.host = process.env.SMTP_HOST
|
||||
if (process.env.SMTP_PORT) {
|
||||
config.smtp.port = parseInt(process.env.SMTP_PORT)
|
||||
}
|
||||
if (process.env.SMTP_SECURE) {
|
||||
config.smtp.secure = process.env.SMTP_SECURE === 'true'
|
||||
}
|
||||
config.smtp.username = process.env.SMTP_USERNAME
|
||||
config.smtp.password = process.env.SMTP_PASSWORD
|
||||
config.smtp.senderAddress = process.env.SMTP_SENDER_ADDRESS
|
||||
config.smtp.senderName = process.env.SMTP_SENDER_NAME
|
||||
}
|
41
Voltaserve/idp/src/config/types.ts
Normal file
41
Voltaserve/idp/src/config/types.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export class Config {
|
||||
port: number
|
||||
publicUIURL: string
|
||||
databaseURL: string
|
||||
token: TokenConfig
|
||||
corsOrigins: string[]
|
||||
search: SearchConfig
|
||||
smtp: SMTPConfig
|
||||
|
||||
constructor() {
|
||||
this.token = new TokenConfig()
|
||||
this.search = new SearchConfig()
|
||||
this.smtp = new SMTPConfig()
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseConfig {
|
||||
url: string
|
||||
}
|
||||
|
||||
export class TokenConfig {
|
||||
jwtSigningKey: string
|
||||
audience: string
|
||||
issuer: string
|
||||
accessTokenLifetime: number
|
||||
refreshTokenLifetime: number
|
||||
}
|
||||
|
||||
export class SearchConfig {
|
||||
url: string
|
||||
}
|
||||
|
||||
export class SMTPConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
senderAddress: string
|
||||
senderName: string
|
||||
}
|
3
Voltaserve/idp/src/infra/date-time.ts
Normal file
3
Voltaserve/idp/src/infra/date-time.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function newDateTime() {
|
||||
return new Date().toISOString()
|
||||
}
|
8
Voltaserve/idp/src/infra/env.ts
Normal file
8
Voltaserve/idp/src/infra/env.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import fs from 'fs'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
if (fs.existsSync('.env.local')) {
|
||||
dotenv.config({ path: '.env.local' })
|
||||
} else {
|
||||
dotenv.config()
|
||||
}
|
119
Voltaserve/idp/src/infra/error.ts
Normal file
119
Voltaserve/idp/src/infra/error.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export enum ErrorCode {
|
||||
InternalServerError = 'internal_server_error',
|
||||
RequestValidationError = 'request_validation_error',
|
||||
UsernameUnavailable = 'username_unavailable',
|
||||
ResourceNotFound = 'resource_not_found',
|
||||
InvalidUsernameOrPassword = 'invalid_username_or_password',
|
||||
InvalidPassword = 'invalid_password',
|
||||
InvalidJwt = 'invalid_jwt',
|
||||
EmailNotConfimed = 'email_not_confirmed',
|
||||
RefreshTokenExpired = 'refresh_token_expired',
|
||||
InvalidRequest = 'invalid_request',
|
||||
UnsupportedGrantType = 'unsupported_grant_type',
|
||||
PasswordValidationFailed = 'password_validation_failed',
|
||||
}
|
||||
|
||||
const statuses: { [key: string]: number } = {
|
||||
[ErrorCode.InternalServerError]: 500,
|
||||
[ErrorCode.RequestValidationError]: 400,
|
||||
[ErrorCode.UsernameUnavailable]: 409,
|
||||
[ErrorCode.ResourceNotFound]: 404,
|
||||
[ErrorCode.InvalidUsernameOrPassword]: 401,
|
||||
[ErrorCode.InvalidPassword]: 401,
|
||||
[ErrorCode.InvalidJwt]: 401,
|
||||
[ErrorCode.EmailNotConfimed]: 401,
|
||||
[ErrorCode.RefreshTokenExpired]: 401,
|
||||
[ErrorCode.InvalidRequest]: 400,
|
||||
[ErrorCode.UnsupportedGrantType]: 400,
|
||||
[ErrorCode.PasswordValidationFailed]: 400,
|
||||
}
|
||||
|
||||
const userMessages: { [key: string]: string } = {
|
||||
[ErrorCode.UsernameUnavailable]: 'Email belongs to an existing user.',
|
||||
[ErrorCode.EmailNotConfimed]: 'Email not confirmed.',
|
||||
[ErrorCode.InvalidPassword]: 'Invalid password.',
|
||||
[ErrorCode.InvalidUsernameOrPassword]: 'Invalid username or password.',
|
||||
}
|
||||
|
||||
export type ErrorData = {
|
||||
code: string
|
||||
status: number
|
||||
message: string
|
||||
userMessage: string
|
||||
moreInfo: string
|
||||
error?: any
|
||||
}
|
||||
|
||||
export type ErrorResponse = {
|
||||
code: string
|
||||
status: number
|
||||
message: string
|
||||
userMessage: string
|
||||
moreInfo: string
|
||||
}
|
||||
|
||||
export type ErrorOptions = {
|
||||
code: ErrorCode
|
||||
message?: string
|
||||
userMessage?: string
|
||||
error?: any
|
||||
}
|
||||
|
||||
export function newError(options: ErrorOptions): ErrorData {
|
||||
const userMessage =
|
||||
options.userMessage ||
|
||||
userMessages[options.code] ||
|
||||
'Oops! something went wrong'
|
||||
return {
|
||||
code: options.code,
|
||||
status: statuses[options.code],
|
||||
message: options.message || userMessage,
|
||||
userMessage,
|
||||
moreInfo: `https://voltaserve.com/docs/idp/errors/${options.code}`,
|
||||
error: options.error,
|
||||
}
|
||||
}
|
||||
|
||||
export function newResponse(data: ErrorData): ErrorResponse {
|
||||
return {
|
||||
code: data.code,
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
userMessage: data.userMessage,
|
||||
moreInfo: data.moreInfo,
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
error: any,
|
||||
_: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
if (error.code && Object.values(ErrorCode).includes(error.code)) {
|
||||
const data = error as ErrorData
|
||||
if (data.error) {
|
||||
console.error(data.error)
|
||||
}
|
||||
res.status(data.status).json(newResponse(data))
|
||||
} else {
|
||||
console.error(error)
|
||||
res
|
||||
.status(500)
|
||||
.json(newResponse(newError({ code: ErrorCode.InternalServerError })))
|
||||
}
|
||||
next(error)
|
||||
return
|
||||
}
|
||||
|
||||
export function parseValidationError(result: any): ErrorData {
|
||||
let message: string
|
||||
if (result.errors) {
|
||||
message = result.errors
|
||||
.map((e: any) => `${e.msg} for ${e.type} ${e.path} in ${e.location}.`)
|
||||
.join(' ')
|
||||
}
|
||||
return newError({ code: ErrorCode.RequestValidationError, message })
|
||||
}
|
10
Voltaserve/idp/src/infra/id.ts
Normal file
10
Voltaserve/idp/src/infra/id.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import hashids from 'hashids'
|
||||
|
||||
export function newHashId(): string {
|
||||
return new hashids(uuidv4()).encode(Date.now())
|
||||
}
|
||||
|
||||
export function newHyphenlessUuid(): string {
|
||||
return uuidv4().replaceAll('-', '')
|
||||
}
|
59
Voltaserve/idp/src/infra/mail.ts
Normal file
59
Voltaserve/idp/src/infra/mail.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import fs from 'fs'
|
||||
import Handlebars from 'handlebars'
|
||||
import yaml from 'js-yaml'
|
||||
import nodemailer from 'nodemailer'
|
||||
import path from 'path'
|
||||
import { getConfig } from '@/config/config'
|
||||
|
||||
type MessageParams = {
|
||||
subject: string
|
||||
}
|
||||
|
||||
const config = getConfig().smtp
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth:
|
||||
config.username || config.password
|
||||
? {
|
||||
user: config.username,
|
||||
pass: config.password,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
export function sendTemplateMail(
|
||||
template: string,
|
||||
address: string,
|
||||
variables: Record<string, any>
|
||||
) {
|
||||
const params = yaml.load(
|
||||
fs.readFileSync(path.join('templates', template, 'params.yml'), 'utf8')
|
||||
) as MessageParams
|
||||
const html = Handlebars.compile(
|
||||
fs.readFileSync(path.join('templates', template, 'template.hbs'), 'utf8')
|
||||
)(variables)
|
||||
const text = Handlebars.compile(
|
||||
fs.readFileSync(path.join('templates', template, 'template.txt'), 'utf8')
|
||||
)(variables)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
transporter.sendMail(
|
||||
{
|
||||
from: `"${config.senderName}" <${config.senderAddress}>`,
|
||||
to: address,
|
||||
subject: params.subject,
|
||||
text,
|
||||
html,
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
6
Voltaserve/idp/src/infra/passport-request.ts
Normal file
6
Voltaserve/idp/src/infra/passport-request.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Request } from 'express'
|
||||
import { User } from '@/user/repo'
|
||||
|
||||
export interface PassportRequest extends Request {
|
||||
user: User
|
||||
}
|
13
Voltaserve/idp/src/infra/password.ts
Normal file
13
Voltaserve/idp/src/infra/password.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { scryptSync, randomBytes } from 'crypto'
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const key = scryptSync(password, salt, 64).toString('hex')
|
||||
return `${key}:${salt}`
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, hash: string): boolean {
|
||||
const [key, salt] = hash.split(':')
|
||||
const newKey = scryptSync(password, salt, 64).toString('hex')
|
||||
return newKey === key
|
||||
}
|
6
Voltaserve/idp/src/infra/postgres.ts
Normal file
6
Voltaserve/idp/src/infra/postgres.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Client } from 'pg'
|
||||
import { getConfig } from '@/config/config'
|
||||
|
||||
export const client = new Client({
|
||||
connectionString: getConfig().databaseURL,
|
||||
})
|
9
Voltaserve/idp/src/infra/search.ts
Normal file
9
Voltaserve/idp/src/infra/search.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MeiliSearch } from 'meilisearch'
|
||||
import { getConfig } from '@/config/config'
|
||||
|
||||
export const USER_SEARCH_INDEX = 'user'
|
||||
|
||||
const client = new MeiliSearch({ host: getConfig().search.url })
|
||||
client.createIndex(USER_SEARCH_INDEX, { primaryKey: 'id' })
|
||||
|
||||
export default client
|
24
Voltaserve/idp/src/token/router.ts
Normal file
24
Voltaserve/idp/src/token/router.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { exchange, TokenExchangeOptions } from './service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const options = req.body as TokenExchangeOptions
|
||||
res.json(await exchange(options))
|
||||
} catch (err) {
|
||||
res.status(400)
|
||||
if (
|
||||
err.error &&
|
||||
(err.error === 'invalid_grant' ||
|
||||
err.error === 'invalid_request' ||
|
||||
err.error === 'unsupported_grant_type')
|
||||
) {
|
||||
res.json(err)
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
136
Voltaserve/idp/src/token/service.ts
Normal file
136
Voltaserve/idp/src/token/service.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { SignJWT } from 'jose'
|
||||
import { getConfig } from '@/config/config'
|
||||
import { ErrorCode, newError } from '@/infra/error'
|
||||
import { newHyphenlessUuid } from '@/infra/id'
|
||||
import { verifyPassword } from '@/infra/password'
|
||||
import userRepo, { User } from '@/user/repo'
|
||||
|
||||
export type TokenGrantType = 'password' | 'refresh_token'
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
|
||||
export type Token = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.2
|
||||
export type TokenExchangeOptions = {
|
||||
grant_type: TokenGrantType
|
||||
username?: string
|
||||
password?: string
|
||||
refresh_token?: string
|
||||
}
|
||||
|
||||
export async function exchange(options: TokenExchangeOptions): Promise<Token> {
|
||||
validateParemeters(options)
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.3
|
||||
if (options.grant_type === 'password') {
|
||||
let user: User
|
||||
try {
|
||||
user = await userRepo.findByUsername(options.username)
|
||||
} catch {
|
||||
throw newError({ code: ErrorCode.InvalidUsernameOrPassword })
|
||||
}
|
||||
if (!user.isEmailConfirmed) {
|
||||
throw newError({ code: ErrorCode.EmailNotConfimed })
|
||||
}
|
||||
if (verifyPassword(options.password, user.passwordHash)) {
|
||||
return newToken(user.id)
|
||||
} else {
|
||||
throw newError({ code: ErrorCode.InvalidUsernameOrPassword })
|
||||
}
|
||||
}
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
||||
if (options.grant_type === 'refresh_token') {
|
||||
let user: User
|
||||
try {
|
||||
user = await userRepo.findByRefreshTokenValue(options.refresh_token)
|
||||
} catch {
|
||||
throw newError({ code: ErrorCode.InvalidUsernameOrPassword })
|
||||
}
|
||||
if (!user.isEmailConfirmed) {
|
||||
throw newError({ code: ErrorCode.EmailNotConfimed })
|
||||
}
|
||||
if (new Date() >= new Date(user.refreshTokenExpiry)) {
|
||||
throw newError({ code: ErrorCode.RefreshTokenExpired })
|
||||
}
|
||||
return newToken(user.id)
|
||||
}
|
||||
}
|
||||
|
||||
function validateParemeters(options: TokenExchangeOptions) {
|
||||
if (!options.grant_type) {
|
||||
throw newError({
|
||||
code: ErrorCode.InvalidRequest,
|
||||
message: 'Missing parameter: grant_type',
|
||||
})
|
||||
}
|
||||
if (
|
||||
options.grant_type !== 'password' &&
|
||||
options.grant_type !== 'refresh_token'
|
||||
) {
|
||||
throw newError({
|
||||
code: ErrorCode.UnsupportedGrantType,
|
||||
message: `Grant type unsupported: ${options.grant_type}`,
|
||||
})
|
||||
}
|
||||
if (options.grant_type === 'password') {
|
||||
if (!options.username) {
|
||||
throw newError({
|
||||
code: ErrorCode.InvalidRequest,
|
||||
message: 'Missing parameter: username',
|
||||
})
|
||||
}
|
||||
if (!options.password) {
|
||||
throw newError({
|
||||
code: ErrorCode.InvalidRequest,
|
||||
message: 'Missing parameter: password',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (options.grant_type === 'refresh_token' && !options.refresh_token) {
|
||||
throw newError({
|
||||
code: ErrorCode.InvalidRequest,
|
||||
message: 'Missing parameter: refresh_token',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function newToken(userId: string): Promise<Token> {
|
||||
const config = getConfig().token
|
||||
const expiry = newAccessTokenExpiry()
|
||||
const jwt = await new SignJWT({ sub: userId })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(config.issuer)
|
||||
.setAudience(config.audience)
|
||||
.setExpirationTime(expiry)
|
||||
.sign(new TextEncoder().encode(config.jwtSigningKey))
|
||||
const token: Token = {
|
||||
access_token: jwt,
|
||||
expires_in: expiry,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: newHyphenlessUuid(),
|
||||
}
|
||||
const user = await userRepo.findByID(userId)
|
||||
await userRepo.update({
|
||||
id: user.id,
|
||||
refreshTokenValue: token.refresh_token,
|
||||
refreshTokenExpiry: newRefreshTokenExpiry(),
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
function newRefreshTokenExpiry(): string {
|
||||
const now = new Date()
|
||||
now.setSeconds(now.getSeconds() + getConfig().token.refreshTokenLifetime)
|
||||
return now.toISOString()
|
||||
}
|
||||
|
||||
function newAccessTokenExpiry(): number {
|
||||
const now = new Date()
|
||||
now.setSeconds(now.getSeconds() + getConfig().token.refreshTokenLifetime)
|
||||
return Math.floor(now.getTime() / 1000)
|
||||
}
|
315
Voltaserve/idp/src/user/repo.ts
Normal file
315
Voltaserve/idp/src/user/repo.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import { ErrorCode, newError } from '@/infra/error'
|
||||
import { client } from '@/infra/postgres'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
fullName: string
|
||||
username: string
|
||||
email: string
|
||||
passwordHash: string
|
||||
refreshTokenValue?: string
|
||||
refreshTokenExpiry?: string
|
||||
resetPasswordToken?: string
|
||||
emailConfirmationToken?: string
|
||||
isEmailConfirmed: boolean
|
||||
emailUpdateToken?: string
|
||||
emailUpdateValue?: string
|
||||
picture?: string
|
||||
createTime: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export type InsertOptions = {
|
||||
id: string
|
||||
fullName?: string
|
||||
username?: string
|
||||
email?: string
|
||||
passwordHash?: string
|
||||
refreshTokenValue?: string
|
||||
refreshTokenExpiry?: string
|
||||
resetPasswordToken?: string
|
||||
emailConfirmationToken?: string
|
||||
isEmailConfirmed?: boolean
|
||||
picture?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export type UpdateOptions = {
|
||||
id: string
|
||||
fullName?: string
|
||||
username?: string
|
||||
email?: string
|
||||
passwordHash?: string
|
||||
refreshTokenValue?: string
|
||||
refreshTokenExpiry?: string
|
||||
resetPasswordToken?: string
|
||||
emailConfirmationToken?: string
|
||||
isEmailConfirmed?: boolean
|
||||
emailUpdateToken?: string
|
||||
emailUpdateValue?: string
|
||||
picture?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface UserRepo {
|
||||
findByID(id: string): Promise<User>
|
||||
findByUsername(username: string): Promise<User>
|
||||
findByEmail(email: string): Promise<User>
|
||||
findByRefreshTokenValue(refreshTokenValue: string): Promise<User>
|
||||
findByResetPasswordToken(resetPasswordToken: string): Promise<User>
|
||||
findByEmailConfirmationToken(emailConfirmationToken: string): Promise<User>
|
||||
findByEmailUpdateToken(emailUpdateToken: string): Promise<User>
|
||||
findByPicture(picture: string): Promise<User>
|
||||
isUsernameAvailable(username: string): Promise<boolean>
|
||||
insert(data: InsertOptions): Promise<User>
|
||||
update(data: UpdateOptions): Promise<User>
|
||||
delete(id: string): Promise<void>
|
||||
}
|
||||
|
||||
class UserRepoImpl {
|
||||
async findByID(id: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE id = $1`,
|
||||
[id]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with id=${id} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE username = $1`,
|
||||
[username]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with username=${username} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE email = $1`,
|
||||
[email]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with email=${email} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByRefreshTokenValue(refreshTokenValue: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE refresh_token_value = $1`,
|
||||
[refreshTokenValue]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with refresh_token_value=${refreshTokenValue} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByResetPasswordToken(resetPasswordToken: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE reset_password_token = $1`,
|
||||
[resetPasswordToken]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with reset_password_token=${resetPasswordToken} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByEmailConfirmationToken(
|
||||
emailConfirmationToken: string
|
||||
): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE email_confirmation_token = $1`,
|
||||
[emailConfirmationToken]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with email_confirmation_token=${emailConfirmationToken} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByEmailUpdateToken(emailUpdateToken: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE email_update_token = $1`,
|
||||
[emailUpdateToken]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with email_update_token=${emailUpdateToken} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async findByPicture(picture: string): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`SELECT * FROM "user" WHERE picture = $1`,
|
||||
[picture]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.ResourceNotFound,
|
||||
error: `User with picture=${picture} not found`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async isUsernameAvailable(username: string): Promise<boolean> {
|
||||
const { rowCount } = await client.query(
|
||||
`SELECT * FROM "user" WHERE username = $1`,
|
||||
[username]
|
||||
)
|
||||
return rowCount === 0
|
||||
}
|
||||
|
||||
async insert(data: InsertOptions): Promise<User> {
|
||||
const { rowCount, rows } = await client.query(
|
||||
`INSERT INTO "user" (
|
||||
id,
|
||||
full_name,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
refresh_token_value,
|
||||
refresh_token_expiry,
|
||||
reset_password_token,
|
||||
email_confirmation_token,
|
||||
is_email_confirmed,
|
||||
picture,
|
||||
create_time
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||
[
|
||||
data.id,
|
||||
data.fullName,
|
||||
data.username,
|
||||
data.email,
|
||||
data.passwordHash,
|
||||
data.refreshTokenValue,
|
||||
data.refreshTokenExpiry,
|
||||
data.resetPasswordToken,
|
||||
data.emailConfirmationToken,
|
||||
data.isEmailConfirmed || false,
|
||||
data.picture,
|
||||
new Date().toISOString(),
|
||||
]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.InternalServerError,
|
||||
error: `Inserting user with id=${data.id} failed`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async update(data: UpdateOptions): Promise<User> {
|
||||
const entity = await this.findByID(data.id)
|
||||
if (!entity) {
|
||||
throw newError({
|
||||
code: ErrorCode.InternalServerError,
|
||||
error: `User with id=${data.id} not found`,
|
||||
})
|
||||
}
|
||||
Object.assign(entity, data)
|
||||
entity.updateTime = new Date().toISOString()
|
||||
const { rowCount, rows } = await client.query(
|
||||
`UPDATE "user"
|
||||
SET
|
||||
full_name = $1,
|
||||
username = $2,
|
||||
email = $3,
|
||||
password_hash = $4,
|
||||
refresh_token_value = $5,
|
||||
refresh_token_expiry = $6,
|
||||
reset_password_token = $7,
|
||||
email_confirmation_token = $8,
|
||||
is_email_confirmed = $9,
|
||||
email_update_token = $10,
|
||||
email_update_value = $11,
|
||||
picture = $12,
|
||||
update_time = $13
|
||||
WHERE id = $14
|
||||
RETURNING *`,
|
||||
[
|
||||
entity.fullName,
|
||||
entity.username,
|
||||
entity.email,
|
||||
entity.passwordHash,
|
||||
entity.refreshTokenValue,
|
||||
entity.refreshTokenExpiry,
|
||||
entity.resetPasswordToken,
|
||||
entity.emailConfirmationToken,
|
||||
entity.isEmailConfirmed,
|
||||
entity.emailUpdateToken,
|
||||
entity.emailUpdateValue,
|
||||
entity.picture,
|
||||
new Date().toISOString(),
|
||||
entity.id,
|
||||
]
|
||||
)
|
||||
if (rowCount < 1) {
|
||||
throw newError({
|
||||
code: ErrorCode.InternalServerError,
|
||||
error: `Inserting user with id=${data.id} failed`,
|
||||
})
|
||||
}
|
||||
return this.mapRow(rows[0])
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await client.query('DELETE FROM "user" WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
private mapRow(row: any): User {
|
||||
return {
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
passwordHash: row.password_hash,
|
||||
refreshTokenValue: row.refresh_token_value,
|
||||
refreshTokenExpiry: row.refresh_token_expiry,
|
||||
resetPasswordToken: row.reset_password_token,
|
||||
emailConfirmationToken: row.email_confirmation_token,
|
||||
isEmailConfirmed: row.is_email_confirmed,
|
||||
emailUpdateToken: row.email_update_token,
|
||||
emailUpdateValue: row.email_update_value,
|
||||
picture: row.picture,
|
||||
createTime: row.create_time,
|
||||
updateTime: row.update_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userRepo: UserRepo = new UserRepoImpl()
|
||||
|
||||
export default userRepo
|
183
Voltaserve/idp/src/user/router.ts
Normal file
183
Voltaserve/idp/src/user/router.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import fs from 'fs/promises'
|
||||
import os from 'os'
|
||||
import { NextFunction, Router, Response } from 'express'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import multer from 'multer'
|
||||
import passport from 'passport'
|
||||
import { parseValidationError } from '@/infra/error'
|
||||
import { PassportRequest } from '@/infra/passport-request'
|
||||
import {
|
||||
deleteUser,
|
||||
getUser,
|
||||
updateFullName,
|
||||
updatePicture,
|
||||
updatePassword,
|
||||
UserDeleteOptions,
|
||||
UserUpdateFullNameOptions,
|
||||
UserUpdatePasswordOptions,
|
||||
deletePicture,
|
||||
UserUpdateEmailRequestOptions,
|
||||
UserUpdateEmailConfirmationOptions,
|
||||
updateEmailRequest,
|
||||
updateEmailConfirmation,
|
||||
} from './service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await getUser(req.user.id))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/update_full_name',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
body('fullName').isString().notEmpty().trim().escape().isLength({ max: 255 }),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
res.json(
|
||||
await updateFullName(req.user.id, req.body as UserUpdateFullNameOptions)
|
||||
)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/update_email_request',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
res.json(
|
||||
await updateEmailRequest(
|
||||
req.user.id,
|
||||
req.body as UserUpdateEmailRequestOptions
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/update_email_confirmation',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
body('token').isString().notEmpty().trim(),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
res.json(
|
||||
await updateEmailConfirmation(
|
||||
req.body as UserUpdateEmailConfirmationOptions
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/update_password',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
body('currentPassword').notEmpty(),
|
||||
body('newPassword').isStrongPassword(),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
res.json(
|
||||
await updatePassword(req.user.id, req.body as UserUpdatePasswordOptions)
|
||||
)
|
||||
} catch (err) {
|
||||
if (err === 'invalid_password') {
|
||||
res.status(400).json({ error: err })
|
||||
return
|
||||
} else {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/update_picture',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
multer({
|
||||
dest: os.tmpdir(),
|
||||
limits: { fileSize: 3000000, fields: 0, files: 1 },
|
||||
}).single('file'),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await updatePicture(
|
||||
req.user.id,
|
||||
req.file.path,
|
||||
req.file.mimetype
|
||||
)
|
||||
await fs.rm(req.file.path)
|
||||
res.json(user)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/delete_picture',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await deletePicture(req.user.id))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
body('password').isString().notEmpty(),
|
||||
async (req: PassportRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = validationResult(req)
|
||||
if (!result.isEmpty()) {
|
||||
throw parseValidationError(result)
|
||||
}
|
||||
await deleteUser(req.user.id, req.body as UserDeleteOptions)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
if (err === 'invalid_password') {
|
||||
res.status(400).json({ error: err })
|
||||
return
|
||||
} else {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
191
Voltaserve/idp/src/user/service.ts
Normal file
191
Voltaserve/idp/src/user/service.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import fs from 'fs/promises'
|
||||
import { ErrorCode, newError } from '@/infra/error'
|
||||
import { hashPassword, verifyPassword } from '@/infra/password'
|
||||
import search, { USER_SEARCH_INDEX } from '@/infra/search'
|
||||
import userRepo, { User } from '@/user/repo'
|
||||
import { newHyphenlessUuid } from '@/infra/id'
|
||||
import { sendTemplateMail } from '@/infra/mail'
|
||||
import { getConfig } from '@/config/config'
|
||||
|
||||
export type UserDTO = {
|
||||
id: string
|
||||
fullName: string
|
||||
picture: string
|
||||
email: string
|
||||
username: string
|
||||
pendingEmail?: string
|
||||
}
|
||||
|
||||
export type UserUpdateEmailRequestOptions = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export type UserUpdateEmailConfirmationOptions = {
|
||||
token: string
|
||||
}
|
||||
|
||||
export type UserUpdateFullNameOptions = {
|
||||
fullName: string
|
||||
}
|
||||
|
||||
export type UserUpdatePasswordOptions = {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export type UserDeleteOptions = {
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function getUser(id: string): Promise<UserDTO> {
|
||||
return mapEntity(await userRepo.findByID(id))
|
||||
}
|
||||
|
||||
export async function getByPicture(picture: string): Promise<UserDTO> {
|
||||
return mapEntity(await userRepo.findByPicture(picture))
|
||||
}
|
||||
|
||||
export async function updateFullName(
|
||||
id: string,
|
||||
options: UserUpdateFullNameOptions
|
||||
): Promise<UserDTO> {
|
||||
let user = await userRepo.findByID(id)
|
||||
user = await userRepo.update({ id: user.id, fullName: options.fullName })
|
||||
await search.index(USER_SEARCH_INDEX).updateDocuments([
|
||||
{
|
||||
...user,
|
||||
fullName: user.fullName,
|
||||
},
|
||||
])
|
||||
return mapEntity(user)
|
||||
}
|
||||
|
||||
export async function updateEmailRequest(
|
||||
id: string,
|
||||
options: UserUpdateEmailRequestOptions
|
||||
): Promise<UserDTO> {
|
||||
let user = await userRepo.findByID(id)
|
||||
if (options.email === user.email) {
|
||||
user = await userRepo.update({
|
||||
id: user.id,
|
||||
emailUpdateToken: null,
|
||||
emailUpdateValue: null,
|
||||
})
|
||||
return mapEntity(user)
|
||||
} else {
|
||||
let usernameUnavailable = false
|
||||
try {
|
||||
await userRepo.findByUsername(options.email)
|
||||
usernameUnavailable = true
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
if (usernameUnavailable) {
|
||||
throw newError({ code: ErrorCode.UsernameUnavailable })
|
||||
}
|
||||
user = await userRepo.update({
|
||||
id: user.id,
|
||||
emailUpdateToken: newHyphenlessUuid(),
|
||||
emailUpdateValue: options.email,
|
||||
})
|
||||
try {
|
||||
await sendTemplateMail('email-update', options.email, {
|
||||
'EMAIL': options.email,
|
||||
'UI_URL': getConfig().publicUIURL,
|
||||
'TOKEN': user.emailUpdateToken,
|
||||
})
|
||||
return mapEntity(user)
|
||||
} catch (error) {
|
||||
await userRepo.update({
|
||||
id,
|
||||
emailUpdateToken: null,
|
||||
emailUpdateValue: null,
|
||||
})
|
||||
throw newError({ code: ErrorCode.InternalServerError, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEmailConfirmation(
|
||||
options: UserUpdateEmailConfirmationOptions
|
||||
) {
|
||||
let user = await userRepo.findByEmailUpdateToken(options.token)
|
||||
user = await userRepo.update({
|
||||
id: user.id,
|
||||
email: user.emailUpdateValue,
|
||||
username: user.emailUpdateValue,
|
||||
emailUpdateToken: null,
|
||||
emailUpdateValue: null,
|
||||
})
|
||||
await search.index(USER_SEARCH_INDEX).updateDocuments([
|
||||
{
|
||||
...user,
|
||||
email: user.email,
|
||||
username: user.email,
|
||||
emailUpdateToken: null,
|
||||
emailUpdateValue: null,
|
||||
},
|
||||
])
|
||||
return mapEntity(user)
|
||||
}
|
||||
|
||||
export async function updatePassword(
|
||||
id: string,
|
||||
options: UserUpdatePasswordOptions
|
||||
): Promise<UserDTO> {
|
||||
let user = await userRepo.findByID(id)
|
||||
if (verifyPassword(options.currentPassword, user.passwordHash)) {
|
||||
user = await userRepo.update({
|
||||
id: user.id,
|
||||
passwordHash: hashPassword(options.newPassword),
|
||||
})
|
||||
return mapEntity(user)
|
||||
} else {
|
||||
throw newError({ code: ErrorCode.PasswordValidationFailed })
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePicture(
|
||||
id: string,
|
||||
path: string,
|
||||
contentType: string
|
||||
): Promise<UserDTO> {
|
||||
const picture = await fs.readFile(path, { encoding: 'base64' })
|
||||
const { id: userId } = await userRepo.findByID(id)
|
||||
const user = await userRepo.update({
|
||||
id: userId,
|
||||
picture: `data:${contentType};base64,${picture}`,
|
||||
})
|
||||
return mapEntity(user)
|
||||
}
|
||||
|
||||
export async function deletePicture(id: string): Promise<UserDTO> {
|
||||
let user = await userRepo.findByID(id)
|
||||
user = await userRepo.update({ id: user.id, picture: null })
|
||||
return mapEntity(user)
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string, options: UserDeleteOptions) {
|
||||
const user = await userRepo.findByID(id)
|
||||
if (verifyPassword(options.password, user.passwordHash)) {
|
||||
await userRepo.delete(user.id)
|
||||
await search.index(USER_SEARCH_INDEX).deleteDocuments([user.id])
|
||||
} else {
|
||||
throw newError({ code: ErrorCode.InvalidPassword })
|
||||
}
|
||||
}
|
||||
|
||||
export function mapEntity(entity: User): UserDTO {
|
||||
const user: UserDTO = {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
username: entity.username,
|
||||
fullName: entity.fullName,
|
||||
picture: entity.picture,
|
||||
pendingEmail: entity.emailUpdateValue,
|
||||
}
|
||||
Object.keys(user).forEach(
|
||||
(index) => !user[index] && user[index] !== undefined && delete user[index]
|
||||
)
|
||||
return user
|
||||
}
|
11
Voltaserve/idp/swagger.js
Normal file
11
Voltaserve/idp/swagger.js
Normal file
@ -0,0 +1,11 @@
|
||||
const swaggerAutogen = require('swagger-autogen')()
|
||||
|
||||
const doc = {
|
||||
info: {
|
||||
title: 'Voltaserve Identity Provider',
|
||||
},
|
||||
}
|
||||
const outputFile = './swagger.json'
|
||||
const endpointsFiles = ['./src/app.ts']
|
||||
|
||||
swaggerAutogen(outputFile, endpointsFiles, doc)
|
1
Voltaserve/idp/templates/email-confirmation/params.yml
Normal file
1
Voltaserve/idp/templates/email-confirmation/params.yml
Normal file
@ -0,0 +1 @@
|
||||
subject: 'Confirm your email address'
|
51
Voltaserve/idp/templates/email-confirmation/template.hbs
Normal file
51
Voltaserve/idp/templates/email-confirmation/template.hbs
Normal file
@ -0,0 +1,51 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 13px;
|
||||
color: black;
|
||||
}
|
||||
.link {
|
||||
color: white !important;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
text-decoration-style: initial;
|
||||
text-decoration-color: initial;
|
||||
background: #0C4CF3;
|
||||
padding: 0 25px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
border-radius: 30px;
|
||||
display: inline-block;
|
||||
}
|
||||
.container {
|
||||
width: 580px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p>Hello,</p>
|
||||
<p>
|
||||
Welcome to Voltaserve! Please confirm your email address to get started.
|
||||
</p>
|
||||
<p>
|
||||
<a class="link" href="{{UI_URL}}/confirm-email/{{TOKEN}}">Confirm email</a>
|
||||
</p>
|
||||
<p>
|
||||
Thanks,<br />
|
||||
Voltaserve
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
1
Voltaserve/idp/templates/email-confirmation/template.txt
Normal file
1
Voltaserve/idp/templates/email-confirmation/template.txt
Normal file
@ -0,0 +1 @@
|
||||
Follow this link to confirm your Email: {{UI_URL}}/confirm-email/{{TOKEN}}
|
1
Voltaserve/idp/templates/email-update/params.yml
Normal file
1
Voltaserve/idp/templates/email-update/params.yml
Normal file
@ -0,0 +1 @@
|
||||
subject: 'Confirm your new email address'
|
51
Voltaserve/idp/templates/email-update/template.hbs
Normal file
51
Voltaserve/idp/templates/email-update/template.hbs
Normal file
@ -0,0 +1,51 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 13px;
|
||||
color: black;
|
||||
}
|
||||
.link {
|
||||
color: white !important;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
text-decoration-style: initial;
|
||||
text-decoration-color: initial;
|
||||
background: #0C4CF3;
|
||||
padding: 0 25px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
border-radius: 30px;
|
||||
display: inline-block;
|
||||
}
|
||||
.container {
|
||||
width: 580px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p>Hello,</p>
|
||||
<p>
|
||||
Please confirm your email address: <b>{{EMAIL}}</b>.
|
||||
</p>
|
||||
<p>
|
||||
<a class="link" href="{{UI_URL}}/update-email/{{TOKEN}}">Confirm email</a>
|
||||
</p>
|
||||
<p>
|
||||
Thanks,<br />
|
||||
Voltaserve
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
1
Voltaserve/idp/templates/email-update/template.txt
Normal file
1
Voltaserve/idp/templates/email-update/template.txt
Normal file
@ -0,0 +1 @@
|
||||
Please follow this link: {{UI_URL}}/update-email/{{TOKEN}}, to confirm your email address: {{EMAIL}}.
|
1
Voltaserve/idp/templates/reset-password/params.yml
Normal file
1
Voltaserve/idp/templates/reset-password/params.yml
Normal file
@ -0,0 +1 @@
|
||||
subject: 'Reset your password'
|
49
Voltaserve/idp/templates/reset-password/template.hbs
Normal file
49
Voltaserve/idp/templates/reset-password/template.hbs
Normal file
@ -0,0 +1,49 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 13px;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
.link {
|
||||
color: white !important;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
text-decoration-style: initial;
|
||||
text-decoration-color: initial;
|
||||
background: #0C4CF3;
|
||||
padding: 0 25px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
border-radius: 30px;
|
||||
display: inline-block;
|
||||
}
|
||||
.container {
|
||||
width: 580px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p>Hello,</p>
|
||||
<p>Follow this link to reset your password.</p>
|
||||
<p>
|
||||
<a class="link" href="{{UI_URL}}/reset-password/{{TOKEN}}">Reset password</a>
|
||||
</p>
|
||||
<p>
|
||||
Thanks,<br />
|
||||
Voltaserve
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
1
Voltaserve/idp/templates/reset-password/template.txt
Normal file
1
Voltaserve/idp/templates/reset-password/template.txt
Normal file
@ -0,0 +1 @@
|
||||
Follow this link to reset your password: {{UI_URL}}/reset-password/{{TOKEN}}
|
15
Voltaserve/idp/tsconfig.json
Normal file
15
Voltaserve/idp/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"target": "es2021",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"outDir": "build",
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user