import path from 'path'
import fs from 'fs'
import get from 'lodash-es/get.js'
import size from 'lodash-es/size.js'
import every from 'lodash-es/every.js'
import iseobj from 'wsemi/src/iseobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isstr from 'wsemi/src/isstr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'
import isErr from 'wsemi/src/isErr.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import cint from 'wsemi/src/cint.mjs'
import strleft from 'wsemi/src/strleft.mjs'
import strright from 'wsemi/src/strright.mjs'
import strdelright from 'wsemi/src/strdelright.mjs'
import pm2resolve from 'wsemi/src/pm2resolve.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import replace from 'wsemi/src/replace.mjs'
import WServHapiServer from 'w-serv-hapi/src/WServHapiServer.mjs'
import WServOrm from 'w-serv-orm/src/WServOrm.mjs'
import ds from '../src/schema/index.mjs'
import * as s from '../src/plugins/mShare.mjs'
import srLogInit, { maskToken } from './srLog.mjs'
import srEmailInit from './srEmail.mjs'
import procCore from './procCore.mjs'
import procLang from './procLang.mjs'
import procProtect from './procProtect.mjs'
import procStaInfor from './procStaInfor.mjs'
import procSettings from './procSettings.mjs'
import { defaultPasswordPolicy } from './defaultPasswordPolicy.mjs'
/**
* WWebSso 單一登入(SSO)伺服器
*
* 建立SSO web服務: 使用者登入/自動登入/登出, 自助註冊與email驗證, 變更/重設密碼與通知信,
* token發放/驗證/更新, 後台使用者/金鑰/IP管理與統計資訊, 以及供外部系統查驗token與使用者資訊之API
*
* @class
* @param {Function} WOrm 輸入資料庫ORM函數
* @param {String} url 輸入資料庫連線字串,例如w-orm-lmdb為'./db',或w-orm-mongodb為'mongodb://username:password@$127.0.0.1:27017'
* @param {String} db 輸入資料庫名稱字串
* @param {String} [pathSettings='./settings.json'] 輸入設定檔案路徑字串,檔案為json5格式,非既存檔案則回退'./settings.json',預設'./settings.json'
* @param {Object} [optExt={}] 輸入額外設定物件,與設定檔內容合併且同名鍵以optExt為準,以下各鍵亦可直接寫於設定檔(各鍵之預設值與範例詳套件自帶settings.json),預設{}
* @param {Integer} [optExt.serverPort=11007] 輸入伺服器通訊port整數,預設11007
* @param {Boolean} [optExt.useCheckUser=false] 輸入是否檢查使用者資訊布林值,預設false
* @param {Function} [optExt.getUserById=null] 輸入當useCheckUser=true時依照使用者ID取得使用者資訊物件函數,預設null
* @param {Boolean} [optExt.useExcludeWhenNotAdmin=false] 輸入使用ORM的select方法時是否自動刪除數據內isActive欄位之布林值,預設false
* @param {Object} [optExt.webName={}] 輸入站台名稱物件,至少包含語系eng與cht鍵的名稱,預設{}
* @param {Object} [optExt.webDescription={}] 輸入站台描述物件,至少包含語系eng與cht鍵的名稱,預設{}
* @param {String} [optExt.webLogo=''] 輸入站台logo字串,採base64格式,預設''
* @param {Array} [optExt.webBackgoundGradientColors=['#FFE0B2', '#FFCC80', '#FFF59D', '#F2D6A2', '#F0CC88']] 輸入使用者登入頁顏色陣列,前3色為背景線性linear-gradient,第4色為登入區塊背景色,第5色為站台logo背景色,若不給則預設白色。預設['#FFE0B2', '#FFCC80', '#FFF59D', '#F2D6A2', '#F0CC88']
* @param {String} [optExt.webKey=''] 輸入站台識別字串,預設''
* @param {String} [optExt.salt=''] 輸入密碼加鹽字串,預設''
* @param {Integer} [optExt.minExpired=30] 輸入創建或更新金鑰有效時間整數,單位分鐘(min),預設30
* @param {Integer} [optExt.minForAccountLoginFailed=10] 輸入限制帳號最大登入失敗(密碼錯誤)次數之判準時間整數,單位分鐘(min),預設10
* @param {Integer} [optExt.numForAccountLoginFailed=3] 輸入限制帳號最大登入失敗(密碼錯誤)之次數整數,預設3
* @param {Integer} [optExt.minBlockForAccountLoginFailed=30] 輸入限制帳號最大登入失敗(密碼錯誤)次數之觸發後封鎖時間整數,單位分鐘(min),預設30
* @param {Integer} [optExt.minForTokenCallApi=10] 輸入限制token最大調用API次數之判準時間整數,單位分鐘(min),預設10
* @param {Integer} [optExt.numForTokenCallApi=1000] 輸入限制token最大調用API之次數整數,預設1000
* @param {Integer} [optExt.minBlockForTokenCallApi=30] 輸入限制token最大調用API次數之觸發後封鎖時間整數,單位分鐘(min),預設30
* @param {Integer} [optExt.minForIpCallApi=10] 輸入限制IP最大調用API次數之判準時間整數,單位分鐘(min),預設10
* @param {Integer} [optExt.numForIpCallApi=10000] 輸入限制IP最大調用API之次數整數,預設10000
* @param {Integer} [optExt.minBlockForIpCallApi=30] 輸入限制IP最大調用API次數之觸發後封鎖時間整數,單位分鐘(min),預設30
* @param {String} [optExt.userLogo=''] 輸入使用者logo字串,採base64格式,預設''
* @param {String} [optExt.subfolder=''] 輸入站台所在子目錄字串,提供站台位於內網採反向代理進行服務時,故需支援位於子目錄情形,預設''
* @param {String} [optExt.mappingBy='email'] 輸入外部系統識別使用者token後所提供之資料物件,與權限系統之使用者資料物件,兩者間查找之對應欄位,可選'id'、'email'、'name',預設'email'
* @param {Object} [optExt.kpLangExt={}] 輸入擴充前端語系物件,預設{}
* @param {String} [optExt.logFd='./logs'] 輸入log紀錄儲存位置字串,預設'./logs'
* @param {String} [optExt.logInterval='hr'] 輸入log紀錄檔案拆檔時長字串,預設'hr'
* @param {Integer} [optExt.logNumKeep=null] 輸入log檔保留數上限正整數,超出者自最舊刪除,未給採w-syslog預設(hr模式365*24、day模式365),預設null
* @param {String} [optExt.emSrcEmail=null] 輸入email寄信用email字串,預設null
* @param {String} [optExt.emSrcPW=null] 輸入email寄信用密碼字串,預設null
* @param {String} [optExt.emSrcHost=null] 輸入email寄信用host字串,預設null
* @param {String} [optExt.emSrcPort=null] 輸入email寄信用port整數,預設null
* @param {Boolean} [optExt.allowUserRegistration=false] 輸入是否開放使用者自助註冊布林值,新功能採opt-in,須明確給true才啟用,預設false
* @param {String} [optExt.siteUrl=''] 輸入站台前端網址字串,選填(目前僅保留供未來使用,尚無功能讀取),預設''
* @param {String} [optExt.verifyBaseUrl=''] 輸入後端API base URL字串,用於組出註冊驗證信內之連結,當allowUserRegistration=true且部署於非本機環境時必填,不給則回退'http://localhost:{serverPort}'(僅本機開發可用),預設''
* @param {Object} [optExt.passwordPolicy={詳見server/defaultPasswordPolicy.mjs}] 輸入密碼政策物件,不給則採程式內建預設;有給則以內建預設為底淺層merge(可只給欲調整之欄位如minLength,未給欄位沿用內建),merge後13個子欄位(minLength、maxLength、requireLetter、requireUppercase、requireLowercase、requireDigit、requireSpecial、noSpace、onlyAscii、forbiddenChars、noConsecutiveCharsFromAccount、consecutiveCharsMinMatch、commonPasswordBlacklist)逐欄驗證,型別錯即啟動時throw,預設內建政策
* @param {Array} [optExt.cleanKpIpCallApiForIps=['127.0.0.1','::1','::ffff:127.0.0.1']] 輸入允許呼叫/api/cleanKpIpCallApi的連線IP白名單陣列,預設本機IP
* @param {String} [optExt.cleanKpIpCallApiForToken=''] 輸入呼叫/api/cleanKpIpCallApi須附帶之識別token字串,預設''
* @param {Array} [optExt.cleanKpAccountLoginFailedForIps=['127.0.0.1','::1','::ffff:127.0.0.1']] 輸入允許呼叫/api/cleanKpAccountLoginFailed的連線IP白名單陣列,預設本機IP
* @param {String} [optExt.cleanKpAccountLoginFailedForToken=''] 輸入呼叫/api/cleanKpAccountLoginFailed須附帶之識別token字串,預設''
* @param {String} [optExt.pathTemplate=''] 輸入自訂結果頁模板資料夾路徑字串,內放verifyEmailResult.html即覆寫內建結果頁模板(缺檔回退套件內建),不給則採內建模板,預設''
* @param {Object} [optExt.chpwEmTitle={}] 輸入變更密碼通知信標題物件,逐語系鍵(eng、cht)各自給文字,或給檔案路徑(絕對或基於啟動路徑之相對,檔案存在即讀檔作為文字,不存在則原樣視為文字),未給之語系採內建語系文字,預設{}
* @param {Object} [optExt.chpwEmContent={}] 輸入變更密碼通知信內容物件,逐語系鍵(eng、cht)各自給HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{sender}與{name}置換符,未給之語系採內建語系文字,預設{}
* @param {Object} [optExt.regVerifyEmTitle={}] 輸入註冊驗證信標題物件,逐語系鍵(eng、cht)各自給文字或檔案路徑(檔案存在即讀檔作為文字),未給之語系採內建語系文字,預設{}
* @param {Object} [optExt.regVerifyEmContent={}] 輸入註冊驗證信內容物件,逐語系鍵(eng、cht)各自給HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{sender}、{name}與{verifyUrl}置換符,未給之語系採內建語系文字,預設{}
* @param {Object} [optExt.resetPwEmTitle={}] 輸入重設密碼通知信標題物件,逐語系鍵(eng、cht)各自給文字或檔案路徑(檔案存在即讀檔作為文字),未給之語系採內建語系文字,預設{}
* @param {Object} [optExt.resetPwEmContent={}] 輸入重設密碼通知信內容物件,逐語系鍵(eng、cht)各自給HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{sender}、{name}、{account}與{newPassword}置換符,未給之語系採內建語系文字,預設{}
* @param {String} [optExt.verifyEmailResultContent=''] 輸入註冊驗證結果頁HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{title}與{message}置換符({message}依請求lang代入對應語系文字故單一模板即可),未給採pathTemplate之verifyEmailResult.html(缺檔回退套件內建模板),預設''
* @returns {Object} 回傳物件,其內server為hapi伺服器實體,wsrv為w-converhp的伺服器事件物件,wsds為w-serv-webdata的伺服器事件物件,可監聽error事件
*/
function WWebSso(WOrm, url, db, pathSettings, optExt = {}) {
let instWServHapiServer = null
//check WOrm
if (!isfun(WOrm)) {
console.log('invalid WOrm', WOrm)
throw new Error('invalid WOrm')
}
//check url
if (!isestr(url)) {
console.log('invalid url', url)
throw new Error('invalid url')
}
//check db
if (!isestr(db)) {
console.log('invalid db', db)
throw new Error('invalid db')
}
//check pathSettings
if (!fsIsFile(pathSettings)) {
pathSettings = './settings.json'
}
//setFilepath
procSettings.setFilepath(pathSettings)
//getSettings
let opt = procSettings.getSettings()
if (iseobj(optExt)) { //外部可傳入額外參數
opt = {
...opt,
...optExt,
}
}
//serverPort
let serverPort = get(opt, 'serverPort')
if (!ispint(serverPort)) {
serverPort = 11007
}
serverPort = cint(serverPort)
//srLog, 提前初始化(僅依賴opt之logFd/logInterval), 使後續啟動期訊息可雙寫入log
let srLog = srLogInit(opt)
//logBoot, 啟動期訊息: console 與 srLog 雙寫(stdout 供互動可見, srLog 供與其他事件同源查閱)
let logBoot = (level, msg) => {
console.log(`[${level.toUpperCase()}] ${msg}`)
srLog[level]({ event: 'boot', msg })
}
//useCheckUser
let useCheckUser = get(opt, 'useCheckUser', false)
//getUserById
let getUserById = get(opt, 'getUserById', null)
//useExcludeWhenNotAdmin
let useExcludeWhenNotAdmin = get(opt, 'useExcludeWhenNotAdmin', false)
//webName
let webName = get(opt, 'webName', {})
//webDescription
let webDescription = get(opt, 'webDescription', {})
//webLogo
let webLogo = get(opt, 'webLogo', '')
//webBackgoundGradientColors
let webBackgoundGradientColors = get(opt, 'webBackgoundGradientColors', [])
if (!isearr(webBackgoundGradientColors)) {
webBackgoundGradientColors = ['#FFE0B2', '#FFCC80', '#FFF59D', '#F2D6A2', '#F0CC88']
}
//webKey
let webKey = get(opt, 'webKey', '')
//salt (作為 per-user scrypt 之伺服器端 pepper; per-user 隨機 salt 已在 hashPassword 內處理)
//D21: 強制要求引用方部署時注入真實 pepper (經 SALT 環境變數); 啟動時若為空/佔位符則拒啟.
let salt = get(opt, 'salt', '')
if (isestr(process.env.SALT)) {
salt = process.env.SALT //生產環境經 SALT env 注入真實 pepper (優先於 settings 佔位符)
}
if (!isestr(salt) || salt === '{salt}') {
//測試/開發可設 ALLOW_PLACEHOLDER_SALT=1 沿用佔位符 (測試密碼非機密); 生產一律須注入真實 SALT.
if (!isestr(process.env.ALLOW_PLACEHOLDER_SALT)) {
throw new Error(`SALT pepper 未設定: 請以環境變數 SALT 注入真實高熵 pepper 後再啟動 (測試/開發可設 ALLOW_PLACEHOLDER_SALT=1 沿用佔位符)`)
}
logBoot('warn', `SALT pepper 為佔位符/空值, 因 ALLOW_PLACEHOLDER_SALT 啟用而放行 — 切勿用於生產環境`)
}
//minExpired, 使用者成功登入後產生token之有效時間(分鐘)
let minExpired = get(opt, 'minExpired', '')
if (!ispnum(minExpired)) {
minExpired = 30
}
//minForAccountLoginFailed, 限制帳號最大登入失敗(密碼錯誤)次數之判準時間(分鐘)
let minForAccountLoginFailed = get(opt, 'minForAccountLoginFailed', '')
if (!ispnum(minForAccountLoginFailed)) {
minForAccountLoginFailed = 10
}
//numForAccountLoginFailed, 限制帳號最大登入失敗(密碼錯誤)之次數
let numForAccountLoginFailed = get(opt, 'numForAccountLoginFailed', '')
if (!ispnum(numForAccountLoginFailed)) {
numForAccountLoginFailed = 3
}
//minBlockForAccountLoginFailed, 限制帳號最大登入失敗(密碼錯誤)次數之觸發後封鎖時間(分鐘)
let minBlockForAccountLoginFailed = get(opt, 'minBlockForAccountLoginFailed', '')
if (!ispnum(minBlockForAccountLoginFailed)) {
minBlockForAccountLoginFailed = 30
}
//minForTokenCallApi, 限制token最大調用API次數之判準時間(分鐘)
let minForTokenCallApi = get(opt, 'minForTokenCallApi', '')
if (!ispnum(minForTokenCallApi)) {
minForTokenCallApi = 10
}
//numForTokenCallApi, 限制token最大調用API之次數
let numForTokenCallApi = get(opt, 'numForTokenCallApi', '')
if (!ispnum(numForTokenCallApi)) {
numForTokenCallApi = 1000
}
//minBlockForTokenCallApi, 限制token最大調用API次數之觸發後封鎖時間(分鐘)
let minBlockForTokenCallApi = get(opt, 'minBlockForTokenCallApi', '')
if (!ispnum(minBlockForTokenCallApi)) {
minBlockForTokenCallApi = 30
}
//minForIpCallApi, 限制IP最大調用API次數之判準時間(分鐘)
let minForIpCallApi = get(opt, 'minForIpCallApi', '')
if (!ispnum(minForIpCallApi)) {
minForIpCallApi = 10
}
//numForIpCallApi, 限制IP最大調用API之次數
let numForIpCallApi = get(opt, 'numForIpCallApi', '')
if (!ispnum(numForIpCallApi)) {
numForIpCallApi = 10000
}
//minBlockForIpCallApi, 限制IP最大調用API次數之觸發後封鎖時間(分鐘)
let minBlockForIpCallApi = get(opt, 'minBlockForIpCallApi', '')
if (!ispnum(minBlockForIpCallApi)) {
minBlockForIpCallApi = 30
}
//cleanKpIpCallApiForIps, 允許呼叫 /api/cleanKpIpCallApi 的連線 IP 白名單
let cleanKpIpCallApiForIps = get(opt, 'cleanKpIpCallApiForIps', [])
if (!isearr(cleanKpIpCallApiForIps)) {
cleanKpIpCallApiForIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1']
}
//cleanKpIpCallApiForToken, 呼叫 /api/cleanKpIpCallApi 須附帶之識別 token
let cleanKpIpCallApiForToken = get(opt, 'cleanKpIpCallApiForToken', '')
//cleanKpAccountLoginFailedForIps, 允許呼叫 /api/cleanKpAccountLoginFailed 的連線 IP 白名單
let cleanKpAccountLoginFailedForIps = get(opt, 'cleanKpAccountLoginFailedForIps', [])
if (!isearr(cleanKpAccountLoginFailedForIps)) {
cleanKpAccountLoginFailedForIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1']
}
//cleanKpAccountLoginFailedForToken, 呼叫 /api/cleanKpAccountLoginFailed 須附帶之識別 token
let cleanKpAccountLoginFailedForToken = get(opt, 'cleanKpAccountLoginFailedForToken', '')
//allowUserRegistration, 是否開放使用者自助註冊, 新功能採opt-in, 未給預設false, 引用方明確設true才啟用(===true嚴格判定, 免'y'等字串誤開)
let allowUserRegistration = get(opt, 'allowUserRegistration', false) === true
//siteUrl, 站台前端網址, 選填(目前僅保留傳遞供未來使用, 尚無功能讀取, 不強制安裝方提供)
let siteUrl = get(opt, 'siteUrl', '')
if (!isestr(siteUrl)) {
siteUrl = ''
}
//passwordPolicy, 未給時採程式內建預設; 有給時以內建預設為底做淺層merge(只給部分欄位如minLength亦可, 未給欄位沿用內建, 未來新增欄位不構成破壞性變更), merge後逐欄嚴格驗證(給了就必須給對)
let passwordPolicy = get(opt, 'passwordPolicy')
if (!iseobj(passwordPolicy)) {
passwordPolicy = defaultPasswordPolicy
logBoot('info', 'settings 未提供 passwordPolicy, 採用程式內建預設密碼政策')
}
else {
passwordPolicy = {
...defaultPasswordPolicy,
...passwordPolicy,
}
}
let ppMinLength = get(passwordPolicy, 'minLength')
if (!ispint(ppMinLength) || cint(ppMinLength) < 1) {
throw new Error('invalid passwordPolicy.minLength: must be a positive integer >= 1')
}
let ppMaxLength = get(passwordPolicy, 'maxLength')
if (!ispint(ppMaxLength) || cint(ppMaxLength) < cint(ppMinLength)) {
throw new Error('invalid passwordPolicy.maxLength: must be a positive integer >= minLength')
}
let ppRequireLetter = get(passwordPolicy, 'requireLetter')
if (!isbol(ppRequireLetter)) {
throw new Error('invalid passwordPolicy.requireLetter: must be a boolean')
}
let ppRequireUppercase = get(passwordPolicy, 'requireUppercase')
if (!isbol(ppRequireUppercase)) {
throw new Error('invalid passwordPolicy.requireUppercase: must be a boolean')
}
let ppRequireLowercase = get(passwordPolicy, 'requireLowercase')
if (!isbol(ppRequireLowercase)) {
throw new Error('invalid passwordPolicy.requireLowercase: must be a boolean')
}
let ppRequireDigit = get(passwordPolicy, 'requireDigit')
if (!isbol(ppRequireDigit)) {
throw new Error('invalid passwordPolicy.requireDigit: must be a boolean')
}
let ppRequireSpecial = get(passwordPolicy, 'requireSpecial')
if (!isbol(ppRequireSpecial)) {
throw new Error('invalid passwordPolicy.requireSpecial: must be a boolean')
}
let ppNoSpace = get(passwordPolicy, 'noSpace')
if (!isbol(ppNoSpace)) {
throw new Error('invalid passwordPolicy.noSpace: must be a boolean')
}
let ppOnlyAscii = get(passwordPolicy, 'onlyAscii')
if (!isbol(ppOnlyAscii)) {
throw new Error('invalid passwordPolicy.onlyAscii: must be a boolean')
}
let ppForbiddenChars = get(passwordPolicy, 'forbiddenChars')
if (!isarr(ppForbiddenChars)) {
throw new Error('invalid passwordPolicy.forbiddenChars: must be an array')
}
for (let i = 0; i < size(ppForbiddenChars); i++) {
if (!isstr(ppForbiddenChars[i]) || size(ppForbiddenChars[i]) !== 1) {
throw new Error(`invalid passwordPolicy.forbiddenChars[${i}]: each element must be a single character string`)
}
}
let ppNoConsecutiveCharsFromAccount = get(passwordPolicy, 'noConsecutiveCharsFromAccount')
if (!isbol(ppNoConsecutiveCharsFromAccount)) {
throw new Error('invalid passwordPolicy.noConsecutiveCharsFromAccount: must be a boolean')
}
if (ppNoConsecutiveCharsFromAccount) {
let ppConsecutiveCharsMinMatch = get(passwordPolicy, 'consecutiveCharsMinMatch')
if (!ispint(ppConsecutiveCharsMinMatch) || cint(ppConsecutiveCharsMinMatch) < 2) {
throw new Error('invalid passwordPolicy.consecutiveCharsMinMatch: must be a positive integer >= 2')
}
}
let ppCommonPasswordBlacklist = get(passwordPolicy, 'commonPasswordBlacklist')
if (!isarr(ppCommonPasswordBlacklist)) {
throw new Error('invalid passwordPolicy.commonPasswordBlacklist: must be an array')
}
if (!every(ppCommonPasswordBlacklist, (v) => isstr(v))) {
throw new Error('invalid passwordPolicy.commonPasswordBlacklist: each element must be a string')
}
//userLogo
let userLogo = get(opt, 'userLogo', '')
//subfolder
let subfolder = get(opt, 'subfolder', '')
if (isestr(subfolder)) {
if (strright(subfolder, 1) === '/') { //右邊不需要給「/」
subfolder = strdelright(subfolder, 1)
}
if (strleft(subfolder, 1) !== '/') { //左邊需要給「/」
subfolder = `/${subfolder}`
}
}
//params
let showLanguage = get(opt, 'showLanguage', 'n')
let language = get(opt, 'language', 'eng')
let showModeEditUsers = get(opt, 'showModeEditUsers', 'n')
let modeEditUsers = get(opt, 'modeEditUsers', 'n')
let showModeEditTokens = get(opt, 'showModeEditTokens', 'n')
let modeEditTokens = get(opt, 'modeEditTokens', 'n')
let showModeEditIps = get(opt, 'showModeEditIps', 'n')
let modeEditIps = get(opt, 'modeEditIps', 'n')
//mappingBy
let mappingBy = get(opt, 'mappingBy', '')
if (mappingBy !== 'id' && mappingBy !== 'email' && mappingBy !== 'name') {
mappingBy = 'email'
}
// console.log('mappingBy', mappingBy)
//kpLangExt
let kpLangExt = get(opt, 'kpLangExt', {})
//srEmail
let srEmail = srEmailInit(opt)
//readTextIfFile, 值若為既存檔案路徑(絕對或基於啟動路徑之相對)則於初始化讀檔內容作為模板或文字, 否則原樣視為文字
let readTextIfFile = (v, label = '') => {
if (!isestr(v)) {
return v
}
let fp = path.resolve(v)
if (fsIsFile(fp)) {
return fs.readFileSync(fp, 'utf8')
}
//疑似檔案路徑(相對/絕對/磁碟機開頭或常見模板副檔名)但檔案不存在: 印WARN(含resolve後絕對路徑, 供排查cwd差異), 仍原樣視為文字
if (/^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(v) || /\.(html|txt)$/i.test(v)) {
logBoot('warn', `settings.${label} 疑似檔案路徑但檔案不存在(${fp}), 將原樣視為文字`)
}
return v
}
//getEmText, 讀取信件文字 settings 鍵(逐語系物件), 各語系值可為文字或檔案路徑(檔案存在即讀檔)
let getEmText = (key) => {
let kp = get(opt, key)
if (!iseobj(kp)) {
return {}
}
let r = {}
Object.keys(kp).forEach((k) => {
r[k] = readTextIfFile(kp[k], `${key}.${k}`)
})
return r
}
//kpLangExtEmail, 信件文字 settings 鍵(逐語系物件), 為安裝方於設定檔客製信件之正式介面;
//讀到才逐語系覆寫 kpLang 內建同名鍵, 其後 kpLangExt 仍可全面覆寫(最終覆寫層, 供測試/CI)
//chpwEmTitle/chpwEmContent: 變更密碼通知信({sender}/{name} 置換)
//regVerifyEmTitle/regVerifyEmContent: 註冊驗證信({sender}/{name}/{verifyUrl} 置換)
//resetPwEmTitle/resetPwEmContent: 重設密碼通知信({sender}/{name}/{account}/{newPassword} 置換)
let kpLangExtEmail = {}
let emTextKeys = ['chpwEmTitle', 'chpwEmContent', 'regVerifyEmTitle', 'regVerifyEmContent', 'resetPwEmTitle', 'resetPwEmContent']
emTextKeys.forEach((k) => {
let kp = getEmText(k)
if (size(kp) > 0) {
kpLangExtEmail[k] = kp
}
})
//kpLang, 語系字典: 內建(procLang) → settings 信件文字鍵(逐語系覆寫) → kpLangExt(整鍵覆寫, 最終層)
let kpLang = procLang({ kpLangExtEmail, kpLangExt, webName, webDescription })
//kpLangWeb, 傳送前端之語系字典: 剔除僅後端寄信使用之信件文字鍵(前端零使用, 避免外洩後端內部文字與增肥前端酬載)
let kpLangWeb = {}
Object.keys(kpLang).forEach((lang) => {
let kp = { ...kpLang[lang] }
emTextKeys.forEach((k) => {
delete kp[k]
})
kpLangWeb[lang] = kp
})
//WServOrm
let optWServOrm = {
useCheckUser,
getUserById,
useExcludeWhenNotAdmin,
}
let wp = {}
try {
wp = WServOrm(ds, WOrm, url, db, optWServOrm)
}
catch (err) {
console.log('WServOrm init failed', err)
throw err
}
let { woItems, procOrm } = wp
//getWebInfor
let getWebInfor = () => {
return {
// webName, //已併入kpLang
// webDescription, //已併入kpLang
webLogo,
webBackgoundGradientColors,
webKey,
userLogo,
showLanguage,
language,
kpLang: kpLangWeb, //前端字典(已剔除後端寄信專用鍵)
showModeEditUsers,
modeEditUsers,
showModeEditTokens,
modeEditTokens,
showModeEditIps,
modeEditIps,
allowUserRegistration,
passwordPolicyInfo: {
minLength: passwordPolicy.minLength,
maxLength: passwordPolicy.maxLength,
requireLetter: passwordPolicy.requireLetter,
requireUppercase: passwordPolicy.requireUppercase,
requireLowercase: passwordPolicy.requireLowercase,
requireDigit: passwordPolicy.requireDigit,
requireSpecial: passwordPolicy.requireSpecial,
consecutiveCharsMinMatch: passwordPolicy.consecutiveCharsMinMatch,
},
}
}
//procCore, procProtect, procStaInfor, procLang
//verifyBaseUrl, 後端 API base URL,用於驗證信內連結; 可由settings提供, 未給回退本機網址(僅本機開發可用)
let verifyBaseUrl = get(opt, 'verifyBaseUrl', '')
if (!isestr(verifyBaseUrl)) {
verifyBaseUrl = `http://localhost:${serverPort}`
if (allowUserRegistration) {
logBoot('warn', `settings 未提供 verifyBaseUrl, 註冊驗證信連結將採 ${verifyBaseUrl}, 非本機部署時收信人將無法完成驗證, 請於 settings 提供對外可連之後端 base URL`)
}
}
//pathTemplateDefault 內建模板資料夾路徑(dev / npm 兩種來源), 現僅存放複雜模板 verifyEmailResult.html(驗證結果頁); 信件文字內建預設在 procLang.mjs
//供本檔 /api/verifyEmail handler 渲染結果頁
let pathTemplateDefault = './server/template'
let npmPathTemplate = './node_modules/w-web-sso/server/template'
if (fsIsFolder(npmPathTemplate)) {
pathTemplateDefault = npmPathTemplate
}
//pathTemplate, 可由settings提供自訂模板資料夾供客製結果頁(verifyEmailResult.html), 缺檔回退內建模板; 未給則直接採內建
let pathTemplate = get(opt, 'pathTemplate', '')
if (!isestr(pathTemplate) || !fsIsFolder(pathTemplate)) {
if (isestr(pathTemplate)) {
logBoot('warn', `settings.pathTemplate 指定之資料夾不存在(${pathTemplate}), 改採套件內建模板`)
}
pathTemplate = pathTemplateDefault
}
//verifyEmailResultContent, 註冊驗證結果頁HTML(內含{title}/{message}置換符), 可由settings直接提供文字或檔案路徑(優先);
//未給則於初始化解析模板檔一次(自訂資料夾缺檔回退內建), 供 /api/verifyEmail handler 置換使用
let verifyEmailResultContent = readTextIfFile(get(opt, 'verifyEmailResultContent', ''), 'verifyEmailResultContent')
if (!isestr(verifyEmailResultContent)) {
let fpTpl = path.resolve(pathTemplate, 'verifyEmailResult.html')
if (!fsIsFile(fpTpl)) {
fpTpl = path.resolve(pathTemplateDefault, 'verifyEmailResult.html')
}
verifyEmailResultContent = fs.readFileSync(fpTpl, 'utf8')
}
let p = procCore(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, passwordPolicy, allowUserRegistration, siteUrl, verifyBaseUrl })
let pp = procProtect(woItems, p, {
srLog, //app token 跳過速率封鎖時記 debug (ADR-052)
minForAccountLoginFailed,
numForAccountLoginFailed,
minBlockForAccountLoginFailed,
minForTokenCallApi,
numForTokenCallApi,
minBlockForTokenCallApi,
minForIpCallApi,
numForIpCallApi,
minBlockForIpCallApi,
})
//D43: 統計子系統(staLogs worker)之 log 掃描目錄須承接設定值, 不可硬讀預設 './logs'(與 srLog 實際寫入位置不一致)
let logFd = get(opt, 'logFd', '')
let pf = procStaInfor(woItems, p, { srLog, logFd })
//kpfun 入口統一 user-input string guard, 防 NoSQL operator injection
//(w-orm-lmdb 之 select 內部用 mingo.Query, 接受 {$ne: null} 等 operator object
//→ 全表 scan + log 噪音; 雖被 _getGenUserByKV duplicate check + verifyPassword
//+ hashPassword 三層防住無 auth bypass, 但 DoS 風險仍存. 詳 ADR-003 Consequences.)
//錯訊統一映射至 ADR-006 'token expired' (token 系) / ADR-003 'incorrect user account or password'
//(login 系) / 'invalid rows' (admin batch update 系), 不洩 type check 失敗 vs business 失敗.
let _strictStr = (...vals) => vals.every((v) => isestr(v))
//錯誤契約 (key-only): kpfun 邊界層一律 reject 後端 key 字串 (= procLang 字典 key 名), 前端以 $t(key)
//顯示 (字典查無則回 key 本身) + 以字串值判斷錯誤種類 (如 err === 'userRegistrationNotVerified').
//內層 (procCore/procProtect) 本就 reject key 字串, 邊界層不再包成 { key, msg } / 不在後端翻譯.
//funCheckAdmin
let funCheckAdmin = (tk, u) => {
// console.log('tk', tk, 'u', u)
let b = s.checkUserAdmin(u)
// console.log('checkUserAdmin', b)
return b
}
//pathStaticFiles
let pathStaticFiles = 'dist'
let npmPathStaticFiles = './node_modules/w-web-sso/dist'
if (fsIsFolder(npmPathStaticFiles)) {
pathStaticFiles = npmPathStaticFiles
}
// console.log('pathStaticFiles', pathStaticFiles)
//subfolder
let fnEntryIn = 'index.tmp'
let fnEntryOut = 'index.html'
try {
let fpEntryIn = path.resolve(pathStaticFiles, fnEntryIn)
if (!fsIsFile(fpEntryIn)) {
fpEntryIn = path.resolve(pathStaticFiles, fnEntryOut) //本機開發另使用html替代tmp
}
if (!fsIsFile(fpEntryIn)) {
console.log('fpEntryIn', fpEntryIn)
throw new Error(`invalid fpEntryIn`)
}
let fpEntryOut = path.resolve(pathStaticFiles, fnEntryOut)
let c = fs.readFileSync(fpEntryIn, 'utf8')
c = replace(c, '/msso/', '{sfd}/') //方法同genEntry
c = replace(c, '{sfd}', subfolder)
c = replace(c, '{language}', language)
fs.writeFileSync(fpEntryOut, c, 'utf8')
}
catch (err) {
console.log(err)
console.log(`can not generate ${fnEntryOut}`)
}
//apis
let apis = [
//sso不使用w-ui-loginout
// {
// method: 'GET',
// path: '/api/getUserByToken', //未登入主頁時需先檢測token, getUserByToken為w-ui-loginout預設值, 若要更改兩邊須同時修改
// },
{
method: 'GET',
path: '/api/cleanKpIpCallApi',
handler: async function (req, res) {
// console.log('cleanKpIpCallApi', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//比對 token 是否為 settings 的 cleanKpIpCallApiForToken, 未設定或不符一律拒絕
if (!isestr(cleanKpIpCallApiForToken)) {
return Promise.reject(`invalid cleanKpIpCallApiForToken`)
}
//比對 token 是否為 settings 的 cleanKpIpCallApiForToken, 未設定或不符一律拒絕
if (token !== cleanKpIpCallApiForToken) {
return Promise.reject(`cleanKpIpCallApi invalid token`)
}
//檢測ip須為 127.0.0.1 或 localhost
//realIp, 取 socket 真實連線位址 (不讀 x-forwarded-for, 該 header 可偽造會繞過本機限制)
let realIp = get(req, 'info.remoteAddress', '')
if (!isestr(realIp)) {
realIp = get(req, 'socket.remoteAddress', '')
}
if (!isestr(realIp)) {
realIp = get(req, 'connection.remoteAddress', '')
}
//僅放行 settings 的 cleanKpIpCallApiForIps 白名單內之連入 IP (供 e2e 防禦性清理 in-memory IP 計數器使用), 其餘一律拒絕
if (!cleanKpIpCallApiForIps.includes(realIp)) {
return Promise.reject(`cleanKpIpCallApi only allowed from cleanKpIpCallApiForIps, got realIp[${realIp}]`)
}
//info
srLog.info({ event: 'api/cleanKpIpCallApi', realIp })
//cleanKpIpCallApi, 清空 server in-memory 的 kpIpCallApi (所有 IP 的調用 API 計數)
await pp.cleanKpIpCallApi()
return true
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('cleanKpIpCallApi', r)
return r
},
},
{
method: 'GET',
path: '/api/cleanKpAccountLoginFailed',
handler: async function (req, res) {
// console.log('cleanKpAccountLoginFailed', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//比對 token 是否為 settings 的 cleanKpAccountLoginFailedForToken, 未設定或不符一律拒絕
if (!isestr(cleanKpAccountLoginFailedForToken)) {
return Promise.reject(`invalid cleanKpAccountLoginFailedForToken`)
}
//比對 token 是否為 settings 的 cleanKpAccountLoginFailedForToken, 未設定或不符一律拒絕
if (token !== cleanKpAccountLoginFailedForToken) {
return Promise.reject(`cleanKpAccountLoginFailed invalid token`)
}
//檢測ip須為 127.0.0.1 或 localhost
//realIp, 取 socket 真實連線位址 (不讀 x-forwarded-for, 該 header 可偽造會繞過本機限制)
let realIp = get(req, 'info.remoteAddress', '')
if (!isestr(realIp)) {
realIp = get(req, 'socket.remoteAddress', '')
}
if (!isestr(realIp)) {
realIp = get(req, 'connection.remoteAddress', '')
}
//僅放行 settings 的 cleanKpAccountLoginFailedForIps 白名單內之連入 IP (供 e2e 防禦性清理 in-memory 失敗計數器使用), 其餘一律拒絕
if (!cleanKpAccountLoginFailedForIps.includes(realIp)) {
return Promise.reject(`cleanKpAccountLoginFailed only allowed from cleanKpAccountLoginFailedForIps, got realIp[${realIp}]`)
}
//info
srLog.info({ event: 'api/cleanKpAccountLoginFailed', realIp })
//cleanKpAccountLoginFailed, 清空 server in-memory 的 kpAccountLoginFailed (所有帳號的登入失敗計數)
await pp.cleanKpAccountLoginFailed()
return true
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('cleanKpAccountLoginFailed', r)
return r
},
},
{
method: 'GET',
path: '/api/logoutSsoUser',
handler: async function (req, res) {
// console.log('logoutSsoUser', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//info
srLog.info({ event: 'api/logoutSsoUser', token: maskToken(token) })
//logoutByToken
let b = await p.logoutByToken(token)
// console.log('b', b)
return b
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('logoutSsoUser', r)
return r
},
},
{
method: 'GET',
path: '/api/checkToken',
handler: async function (req, res) {
// console.log('checkToken', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//info
srLog.info({ event: 'api/checkToken', token: maskToken(token) })
//callApiByToken
pp.callApiByToken(token)
//checkToken
let b = await p.checkToken(token)
// console.log('b', b)
return b
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('checkToken', r)
return r
},
},
{
method: 'GET',
path: '/api/refreshToken',
handler: async function (req, res) {
// console.log('refreshToken', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//info
srLog.info({ event: 'api/refreshToken', token: maskToken(token) })
//callApiByToken
pp.callApiByToken(token)
//refreshToken
let b = await p.refreshToken(token)
// console.log('b', b)
return b
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('refreshToken', r)
return r
},
},
{
method: 'GET',
path: '/api/logoutByToken',
handler: async function (req, res) {
// console.log('logoutByToken', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//info
srLog.info({ event: 'api/logoutByToken', token: maskToken(token) })
//callApiByToken
pp.callApiByToken(token)
//logoutByToken
let b = await p.logoutByToken(token)
// console.log('b', b)
return b
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('logoutByToken', r)
return r
},
},
{
method: 'GET',
path: '/api/getSsoUsersList',
handler: async function (req, res) {
// console.log('getSsoUsersList', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//info
srLog.info({ event: 'api/getSsoUsersList', token: maskToken(token) })
//callApiByToken
pp.callApiByToken(token)
//getSsoUsersList: 收緊為 admin-only (對齊 ADR-004 同款 IDOR 收緊 + 下方 kpfun 版本帶 funCheckAdmin).
//app token (isApp='y') 由 _checkTokenByObj 之 ADR-005 語意自動 bypass fun, 系統介接不影響.
let us = await p.checkTokenAndGetUsersList(token, { fun: funCheckAdmin })
// console.log('us', us)
return us
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('getSsoUsersList', r)
return r
},
},
{
method: 'GET',
path: '/api/getSsoUserInfor',
handler: async function (req, res) {
// console.log('getSsoUserInfor', req)
async function core() {
//token
let token = get(req, 'query.token', '')
// console.log('token', token)
//key
let key = get(req, 'query.key', '')
// console.log('key', key)
//value
let value = get(req, 'query.value', '')
// console.log('value', value)
//check(入口一致性, 對齊 kpfun getUserInfor 之 guard)
if (!_strictStr(token, key, value)) return Promise.reject('tokenExpired')
//valueLog: key==='token' 時 value 即明文目標 token, 記 log 前需遮罩
let valueLog = (key === 'token') ? maskToken(value) : value
//info
srLog.info({ event: 'api/getSsoUserInfor', token: maskToken(token), key, value: valueLog })
//callApiByToken
pp.callApiByToken(token)
//userTarget
let userTarget = null
if (key === 'token') {
userTarget = await p.checkTokenAndGetUserByToken(token, value, { fun: funCheckAdmin })
// console.log('checkTokenAndGetUserByToken userTarget', userTarget)
}
else {
userTarget = await p.checkTokenAndGetUserInfor(token, key, value, { fun: funCheckAdmin })
// console.log('checkTokenAndGetUserInfor userTarget', userTarget)
}
//check
if (!iseobj(userTarget)) {
// console.log('token', token)
// console.log('key', key, 'value', value)
console.log(`token does not have permission`)
return Promise.reject('tokenNoPermission') //原則5: 外部 API 錯誤回乾淨 key
}
return userTarget
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
// console.log('getSsoUserInfor', r)
return r
},
},
{
method: 'GET',
path: '/api/verifyEmail',
handler: async function (req, res) {
async function core() {
//token
let token = get(req, 'query.token', '')
//info
srLog.info({ event: 'api/verifyEmail', token: maskToken(token) })
//verifyEmail
let r = await p.verifyEmail(token)
return r
}
//pm2resolve core
let r = await pm2resolve(core)()
if (isErr(r.msg)) {
r.msg = r.msg.message
}
if (r.state === 'error') {
srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
}
//lang from query (依語系直接渲染結果頁,避免轉址回 SPA 後 lang 重置)
let lang = get(req, 'query.lang', '')
if (lang !== 'eng' && lang !== 'cht') {
lang = 'eng'
}
//pick message key
let msgKey
if (r.state === 'success') {
msgKey = 'userRegistrationVerifySuccess'
}
else {
msgKey = (r.msg && typeof r.msg === 'string') ? r.msg : 'verifyEmailInvalidToken'
}
//render (verifyEmailResultContent 已於初始化解析: settings 直接提供優先, 否則模板檔)
let title = get(kpLang, `${lang}.webName`, '')
let message = get(kpLang, `${lang}.${msgKey}`, msgKey)
let html = verifyEmailResultContent
html = replace(html, '{title}', title)
html = replace(html, '{message}', message)
return res.response(html).type('text/html; charset=utf-8')
},
},
]
//WServHapiServer
let optHapi = {
port: opt.serverPort,
pathStaticFiles,
apiName: 'api',
apis,
tokenType: 'Bearer',
verifyConn: async ({ apiType, authorization, query, headers, req }) => {
// console.log('headers', headers)
//getIpByHeaders
let ip = pp.getIpByHeaders(req)
// console.log('ip', ip)
//origin
let origin = get(headers, 'origin', '')
//referer
let referer = get(headers, 'referer', '')
//info
srLog.info({ event: 'verifyConn', ip, origin, referer })
//callApiByIp
pp.callApiByIp(ip)
//getBlockedByIp
let b = true
await pp.getBlockedByIp(ip)
.then((res) => {
// console.log('pp.getBlockedByIp(ip) then', res)
b = res
})
.catch(() => {
// console.log('pp.getBlockedByIp(ip) catch', err)
b = true
})
// console.log('pp.getBlockedByIp(ip)', b)
//b改為代表通行(未封鎖)狀態
b = !b
// //getInforsByIpCallApi
// let rs = await pp.getInforsByIpCallApi(ip)
// console.log('getInforsByIpCallApi', [
// rs[0],
// rs[rs.length - 1],
// ], rs.length)
//logshow
if (!b) {
console.log(`block ip[${ip}]`) //[tag:測試:顯示被封鎖ip]
}
return b
},
getUserIdByToken: async (token) => { //可使用async或sync函數
return '' //不檢核, 查找與變更數據皆由kpFunExt內各函數各自檢核
},
corsOrigins: ['*'],
useDbOrm: false,
// kpOrm: woItems,
// operOrm: procOrm, //procOrm的輸入為: userId, tableName, methodName, input
tableNamesExec: [],
methodsExec: [], //['select', 'insert', 'save', 'del', 'delAll'],
tableNamesSync: [],
kpFunExt: { //接收參數第1個為userId, 之後才是前端給予參數
getWebInfor: async (_t) => {
return getWebInfor()
},
checkToken: async (_t, token) => { //sso前端通過$fapi.checkToken調用
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-checkToken', token: maskToken(token) })
let r = await p.checkToken(token)
return r
},
refreshToken: async (_t, token) => { //sso前端通過$fapi.refreshToken調用
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-refreshToken', token: maskToken(token) })
let r = await p.refreshToken(token)
return r
},
loginByAccountAndPassword: async (_t, lang, account, password) => {
if (!_strictStr(account, password)) return Promise.reject('failedLoginForCatch')
//info, 使用者登入前
srLog.info({ event: 'kpfun-loginByAccountAndPassword-before', account })
let u = null
let msg = ''
await pp.loginByAccountAndPassword(account, password) //記得不能使用p.loginByAccountAndPassword, 此未提供保護機制
.then((res) => {
u = res
})
.catch((err) => {
msg = err
})
//info, 使用者登入成功或失敗
if (iseobj(u)) {
srLog.info({ event: 'kpfun-loginByAccountAndPassword-success', account })
}
else {
srLog.info({ event: 'kpfun-loginByAccountAndPassword-error', account, msg })
}
if (iseobj(u)) {
return u
}
//msg 為 procCore/procProtect reject 之 key (failedLoginForCatch / userRegistrationNotVerified
/// loginAccountExpired / loginAccountBlocked), 直接以 key 字串 reject 回前端 (前端 $t 顯示)
return Promise.reject(msg)
},
logoutByToken: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-logoutByToken', token: maskToken(token) })
let r = await p.logoutByToken(token)
return r
},
createUser: async (_t, lang, account, password, confirmPassword, name, email) => {
srLog.info({ event: 'kpfun-createUser', lang, account })
let data = { lang, account, password, confirmPassword, name, email }
let r = await p.createUser(lang, data)
return r
},
verifyEmail: async (_t, token) => {
srLog.info({ event: 'kpfun-verifyEmail', token: maskToken(token) })
let r = await p.verifyEmail(token)
return r
},
resendVerifyEmail: async (_t, lang, account, email) => {
srLog.info({ event: 'kpfun-resendVerifyEmail', lang, account })
let r = await p.resendVerifyEmail(lang, account, email)
return r
},
getUserByToken: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getUserByToken', token: maskToken(token) })
//console.log('call getUserByToken...')
let r = await p.checkTokenAndGetUserByToken(token, token)
//console.log('call getUserByToken end')
return r
},
getUserInfor: async (_t, token, key, value) => {
if (!_strictStr(token, key, value)) return Promise.reject('tokenExpired')
//valueLog: key==='token' 時 value 即明文目標 token, 記 log 前需遮罩
let valueLog = (key === 'token') ? maskToken(value) : value
srLog.info({ event: 'kpfun-getUserInfor', token: maskToken(token), key, value: valueLog })
//console.log('call getUserInfor...')
let r = await p.checkTokenAndGetUserInfor(token, key, value, { fun: funCheckAdmin })
//console.log('call getUserInfor end')
return r
},
checkUserPassword: async (_t, lang, pw) => {
//僅記 event / lang, 不記任何密碼明文 (對齊 ADR-014 + adminResetUserPassword 之記法).
srLog.info({ event: 'kpfun-checkUserPassword', lang })
//console.log('call checkUserPassword...')
let r = await p.checkUserPassword(lang, pw)
//console.log('call checkUserPassword end')
return r
},
changeUserPassword: async (_t, token, lang, pwOld, pwNew) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
//僅記 event / token / lang, 不記任何密碼明文 (對齊 ADR-014 + adminResetUserPassword 之記法).
srLog.info({ event: 'kpfun-changeUserPassword', token: maskToken(token), lang })
//console.log('call checkTokenAndChangePassword...')
let r = await p.checkTokenAndChangePassword(token, lang, pwOld, pwNew)
//console.log('call checkTokenAndChangePassword end')
return r
},
adminResetUserPassword: async (_t, token, lang, targetUserId) => {
if (!_strictStr(token, targetUserId)) return Promise.reject('tokenExpired')
//僅記 event / token / targetUserId, 不記任何密碼明文
srLog.info({ event: 'kpfun-adminResetUserPassword', token: maskToken(token), lang, targetUserId })
let r = await p.adminResetUserPassword(token, lang, targetUserId)
return r
},
getUsersList: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getUsersList', token: maskToken(token) })
//console.log('call getUsersList...')
let rs = await p.checkTokenAndGetUsersList(token, { fun: funCheckAdmin })
//console.log('call getUsersList end')
return rs
},
updateUsersList: async (_t, token, lang, rows) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
if (!isearr(rows) || !rows.every((r) => isestr(get(r, 'id', '')))) return Promise.reject('invalidRows')
srLog.info({ event: 'kpfun-updateUsersList', token: maskToken(token), lang })
//console.log('call updateUsersList...')
//內層 reject key 字串 (含 checkToken 之 tokenExpired) 直接透出回前端 (前端 $t 顯示)
let rs = await p.checkTokenAndUpdateUsersList(token, lang, rows, { fun: funCheckAdmin })
//console.log('call updateUsersList end')
return rs
},
getTokensList: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getTokensList', token: maskToken(token) })
//console.log('call getTokensList...')
let rs = await p.checkTokenAndGetTokensList(token, { fun: funCheckAdmin })
//console.log('call getTokensList end')
return rs
},
updateTokensList: async (_t, token, lang, rows) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
if (!isearr(rows) || !rows.every((r) => isestr(get(r, 'id', '')))) return Promise.reject('invalidRows')
srLog.info({ event: 'kpfun-updateTokensList', token: maskToken(token), lang })
//console.log('call updateTokensList...')
let rs = await p.checkTokenAndUpdateTokensList(token, rows, { fun: funCheckAdmin })
//console.log('call updateTokensList end')
return rs
},
getIpsList: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getIpsList', token: maskToken(token) })
//console.log('call getIpsList...')
let rs = await p.checkTokenAndGetIpsList(token, { fun: funCheckAdmin })
//console.log('call getIpsList end')
return rs
},
updateIpsList: async (_t, token, lang, rows) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
if (!isearr(rows) || !rows.every((r) => isestr(get(r, 'id', '')))) return Promise.reject('invalidRows')
srLog.info({ event: 'kpfun-updateIpsList', token: maskToken(token), lang })
//console.log('call updateIpsList...')
let rs = await p.checkTokenAndUpdateIpsList(token, rows, { fun: funCheckAdmin })
//console.log('call updateIpsList end')
return rs
},
getStaUserSummary: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getStaUserSummary', token: maskToken(token) })
//console.log('call getStaUserSummary...')
let r = await pf.checkTokenAndGetStaUserSummary(token, { fun: funCheckAdmin })
//console.log('call getStaUserSummary end')
return r
},
getStaTokenSummary: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getStaTokenSummary', token: maskToken(token) })
//console.log('call getStaTokenSummary...')
let r = await pf.checkTokenAndGetStaTokenSummary(token, { fun: funCheckAdmin })
//console.log('call getStaTokenSummary end')
return r
},
getStaIpSummary: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getStaIpSummary', token: maskToken(token) })
//console.log('call getStaIpSummary...')
let r = await pf.checkTokenAndGetStaIpSummary(token, { fun: funCheckAdmin })
//console.log('call getStaIpSummary end')
return r
},
getStaUserAccountLogin: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getStaUserAccountLogin', token: maskToken(token) })
//console.log('call getStaUserAccountLogin...')
let r = await pf.checkTokenAndGetStaUserAccountLogin(token, { fun: funCheckAdmin })
//console.log('call getStaUserAccountLogin end')
return r
},
getStaToken: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getStaToken', token: maskToken(token) })
//console.log('call getStaToken...')
let r = await pf.checkTokenAndGetStaToken(token, { fun: funCheckAdmin })
//console.log('call getStaToken end')
return r
},
getStaIp: async (_t, token) => {
if (!_strictStr(token)) return Promise.reject('tokenExpired')
srLog.info({ event: 'kpfun-getStaIp', token: maskToken(token) })
//console.log('call getStaIp...')
let r = await pf.checkTokenAndGetStaIp(token, { fun: funCheckAdmin })
//console.log('call getStaIp end')
return r
},
},
fnTableTags: 'tableTags-web-sso.json',
}
//原則3: 後端錯誤統一 log error key. 集中包一層 kpFunExt — 任一 kpfun reject 時記 { fun, key } 再原樣
//re-throw (不改既有行為/回傳契約). key 為字串時直接記; 非字串 (Error/物件, 內部 invariant) 取 message.
optHapi.kpFunExt = Object.fromEntries(Object.entries(optHapi.kpFunExt).map(([fnName, fn]) => [
fnName,
async (...args) => {
try {
return await fn(...args)
}
catch (err) {
srLog.error({ event: 'kpfun-error', fun: fnName, key: isestr(err) ? err : (isErr(err) ? err.message : String(err)) })
throw err
}
},
]))
instWServHapiServer = new WServHapiServer(optHapi)
return instWServHapiServer
}
export default WWebSso