2026-10-26 11:02:58 +00:00
'use strict' ;
// 회원(id, 비밀번호, 이름/회사명) 저장과 세션 쿠키 처리. 의존성 없음.
// v1.3.17 보안 점검(reports/code-review-2026-10-07.md) 반영:
// S01 세션 값은 JSON(아이디·만료·세대)을 서명한 형식으로 바꿔, 점(.)이 든 아이디로 다른 계정이 되는 문제를 막았다.
// S05 비밀번호를 바꾸거나 재설정하면 사용자별 세션 세대(sessionGen)가 올라가 그 전에 발급한 세션이 모두 무효가 된다.
// 비밀번호 규칙은 lib/passpolicy.js (관리자 콘솔에서 설정, 기본은 기존 규칙 "6자 이상").
const fs = require ( 'fs' );
const path = require ( 'path' );
const crypto = require ( 'crypto' );
const passpolicy = require ( './passpolicy' );
const DATA_ROOT = process . env . DATA_ROOT || path . join ( __dirname , '..' , 'data' );
const USERS_FILE = path . join ( DATA_ROOT , 'users.json' );
const SECRET_FILE = path . join ( DATA_ROOT , 'session.secret' );
const COOKIE = 'wh_session' ;
const SESSION_DAYS = 30 ;
const ID_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/ ;
fs . mkdirSync ( DATA_ROOT , { recursive : true });
let SECRET ;
try { SECRET = fs . readFileSync ( SECRET_FILE , 'utf8' ). trim (); } catch ( _ ) { SECRET = '' ; }
if ( ! SECRET ) { SECRET = crypto . randomBytes ( 32 ). toString ( 'hex' ); fs . writeFileSync ( SECRET_FILE , SECRET , { mode : 0o600 }); }
// v1.6.31 (요구 10): users.json 이 깨졌으면 "사용자 없음" 으로 보지 않는다(그러면 기본 관리자 생성·가입 중복 같은 2차 사고가 난다) — 예외로 멈추고 관리자 확인
const js = require ( './jsonstore' );
function loadUsers () { const d = js . readJson ( USERS_FILE , null , { strict : true }); return d && typeof d === 'object' ? d : {}; }
function saveUsers ( u ) { js . writeJsonAtomic ( USERS_FILE , JSON . stringify ( u , null , 2 ), { mode : 0o600 , keepBackup : true }); }
function hashPassword ( pw , salt ) { return crypto . scryptSync ( pw , salt , 64 ). toString ( 'hex' ); }
function httpError ( msg , status = 400 ) { return Object . assign ( new Error ( msg ), { status }); }
const ADMIN_USERS = new Set (( process . env . ADMIN_USERS || '' ). split ( ',' ). map (( x ) => x . trim (). toLowerCase ()). filter ( Boolean ));
function roleOf ( u ) { return u . role === 'admin' || ADMIN_USERS . has ( u . id ) ? 'admin' : 'user' ; }
// v1.4.0 accountType: 'subdomain'(플랫폼 서브 도메인만) | 'domain'(자기 도메인 구매·관리). 기록이 없는 1.4.0 이전 가입자는 'domain' 으로 본다(lib/domains.accountTypeOf)
// v1.5.11 mustChangePassword: 기본 관리자(admin/admin)처럼 "알려진 비밀번호" 로 만든 계정 — 로그인 뒤 다른 일을 하기 전에 비밀번호를 바꿔야 한다(server.js 가 막는다)
// v1.5.27 (요구 6) pendingApproval: 관리자 승인 방식으로 가입해 아직 승인되지 않은 계정 — 로그인이 403 으로 막힌다. approvedAt/approvedBy 는 승인 기록
function publicUser ( u ) { return { id : u . id , name : u . name , role : roleOf ( u ), createdAt : u . createdAt , accountType : u . accountType || null , mustChangePassword : !! u . mustChangePassword , lastLoginAt : u . lastLoginAt || null , pendingApproval : !! u . pendingApproval , approvedAt : u . approvedAt || null }; }
// ============================================================================
// v1.5.11 (요구 1): 기본 관리자 계정 admin / admin
//
// 처음 설치한 서버에는 관리자 계정이 없었다(관리자는 scripts/admin.js 나 콘솔에서 따로 만들어야 했고, 설치 안내에도 없었다).
// 그래서 설치 직후 회원가입이 막혀 있으면(웹서버 규칙·설정) 아무도 들어갈 수 없었고, 운영자가 당연히 있을 거라 여긴 admin/admin 도 없었다.
// 이제 "사용자가 한 명도 없는" 서버가 기동하면 admin / admin 을 만든다(ensureBootstrapAdmin). 비밀번호 admin 은 규칙(6자 이상)에
// 맞지 않지만 일부러 그대로 두고, 대신 mustChangePassword 표시를 붙여 첫 로그인 뒤 비밀번호를 바꾸기 전에는 아무 것도 못 하게 한다.
// 이미 사용자가 있는 서버(운영 중)에는 자동으로 만들지 않는다 — 누군가 admin/admin 으로 들어올 수 있는 구멍이 되기 때문.
// 운영자가 다른 방식으로 설치해 관리자 비밀번호를 모르는 서버(관리자는 있으나 한 번도 로그인한 적이 없음)에서는
// 초기 설정 페이지(접속 토큰)나 `node scripts/admin.js bootstrap` 으로 같은 계정을 만들 수 있다(bootstrapAdmin).
// ============================================================================
const BOOTSTRAP_ID = 'admin' ;
const BOOTSTRAP_PASSWORD = 'admin' ;
/**
* admin / admin 계정을 만든다(이미 있으면 그대로). 비밀번호 규칙을 거치지 않고 바로 저장하며 mustChangePassword 를 켠다.
* @param {string} by 기록용 출처 (boot | setup | cli)
* @returns {{created:boolean, user:object}}
*/
function bootstrapAdmin ( by = 'boot' ) {
const users = loadUsers ();
if ( users [ BOOTSTRAP_ID ]) return { created : false , user : publicUser ( users [ BOOTSTRAP_ID ]) };
const salt = crypto . randomBytes ( 16 ). toString ( 'hex' );
// v1.6.31: 비밀번호는 설치마다 무작위(1회 표시) — 고정값 admin 은 쓰지 않는다. 첫 로그인 뒤 변경을 강제한다
const oneTime = crypto . randomBytes ( 9 ). toString ( 'base64url' );
users [ BOOTSTRAP_ID ] = { id : BOOTSTRAP_ID , name : '관리자' , salt , passHash : hashPassword ( oneTime , salt ), role : 'admin' , accountType : 'domain' , mustChangePassword : true , bootstrappedAt : new Date (). toISOString (), bootstrappedBy : by , createdAt : new Date (). toISOString () };
saveUsers ( users );
return { created : true , user : publicUser ( users [ BOOTSTRAP_ID ]), password : oneTime };
}
/** 기동 때: 사용자가 한 명도 없으면 기본 관리자를 만든다. 결과 메시지(로그용) 또는 null */
/**
* v1.6.31 (요구 6, 검토 S5): 기동 때 admin/admin 을 만들던 동작을 없앴다. 사용자가 없는 새 설치는 초기 설정 페이지(접속 토큰)에서 "최초 관리자 만들기" 로
* 운영자가 아이디·비밀번호를 정한다. 이 함수는 호환을 위해 남겨 두되 아무 것도 만들지 않고 안내만 돌려준다.
*/
function ensureBootstrapAdmin () {
if ( Object . keys ( loadUsers ()). length ) return null ;
return '사용자가 없습니다 — 초기 설정 페이지(설치 스크립트가 알려 준 접속 토큰 주소)에서 최초 관리자 계정을 만드세요 (v1.6.31: 고정 비밀번호 admin/admin 은 더 이상 만들지 않습니다)' ;
}
function _legacyEnsureBootstrapAdmin () {
const users = loadUsers ();
if ( Object . keys ( users ). length ) return null ;
const r = bootstrapAdmin ( 'boot' );
return r . created ? `사용자가 없어 기본 관리자 계정을 만들었습니다: ${ BOOTSTRAP_ID } / ${ BOOTSTRAP_PASSWORD } (첫 로그인 뒤 비밀번호를 바꿔야 합니다)` : null ;
}
/**
* 관리자 계정 상태 (초기 설정 페이지·설치 점검표용).
* admins : 관리자 목록(아이디·마지막 로그인·비밀번호 변경 필요 여부)
* usable : 비밀번호가 기본값이 아닌 관리자가 하나라도 있나 (= 운영 가능한 관리자가 있나)
* defaultOpen : 기본 비밀번호(admin) 그대로인 관리자가 남아 있나 → 점검표에서 "바꾸세요"
* anyLogin : 관리자 중 한 번이라도 로그인한 계정이 있나 (운영자가 비밀번호를 아는 계정이 있다는 뜻)
*/
function adminState () {
const list = Object . values ( loadUsers ()). filter (( u ) => roleOf ( u ) === 'admin' ). map ( publicUser );
return { admins : list , usable : list . some (( u ) => ! u . mustChangePassword ), defaultOpen : list . some (( u ) => u . mustChangePassword ), anyLogin : list . some (( u ) => !! u . lastLoginAt ), hasBootstrap : list . some (( u ) => u . id === BOOTSTRAP_ID ), total : Object . keys ( loadUsers ()). length };
}
/**
* 회원가입. v1.5.27 (요구 6): opts.pending 이면 승인 대기 계정으로 만든다(관리자 승인 전까지 로그인 불가). 호출자(server.js)가 설정 signupApproval 을 보고 정한다.
*/
function signup ({ id , password , name }, opts = {}) {
id = String ( id || '' ). trim (). toLowerCase (); name = String ( name || '' ). trim (); password = String ( password || '' );
if ( ! ID_RE . test ( id )) throw httpError ( '아이디는 영문 소문자/숫자로 시작하는 3~32자(영문, 숫자, . _ -)여야 합니다' );
passpolicy . check ( password , { id });
if ( ! name || name . length > 80 ) throw httpError ( '이름(회사명)을 입력하세요 (80자 이내)' );
const users = loadUsers ();
if ( users [ id ]) throw httpError ( '이미 사용 중인 아이디입니다' , 409 );
const salt = crypto . randomBytes ( 16 ). toString ( 'hex' );
// v1.4.0 새 가입자의 계정 종류 (관리자 설정). 플랫폼 도메인이 없으면(서비스 주소가 IP 등) 서브 도메인을 쓸 수 없으므로 자체 도메인으로 둔다
let accountType = 'subdomain' ; try { const dc = require ( './domains' ). config (); accountType = dc . defaultAccountType === 'domain' || ! dc . platformDomain ? 'domain' : 'subdomain' ; } catch ( _ ) {}
users [ id ] = { id , name , salt , passHash : hashPassword ( password , salt ), role : 'user' , accountType , createdAt : new Date (). toISOString (), ...( opts . pending ? { pendingApproval : true } : {}) };
saveUsers ( users );
return publicUser ( users [ id ]);
}
/** 관리자 생성/승격 등 내부용: 역할을 지정해 계정을 만든다(이미 있으면 역할만 갱신) */
function createUser ({ id , password , name , role = 'user' }) {
const users = loadUsers (); id = String ( id || '' ). trim (). toLowerCase ();
if ( ! users [ id ]) { signup ({ id , password , name }); }
const u = loadUsers (); u [ id ]. role = role ; if ( password && users [ id ]) { passpolicy . check ( password , { id }); u [ id ]. salt = crypto . randomBytes ( 16 ). toString ( 'hex' ); u [ id ]. passHash = hashPassword ( String ( password ), u [ id ]. salt ); u [ id ]. sessionGen = ( u [ id ]. sessionGen || 0 ) + 1 ; }
saveUsers ( u ); return publicUser ( u [ id ]);
}
/** 본인 비밀번호 변경 (현재 비밀번호 확인) */
function changePassword ( id , current , next ) {
const users = loadUsers (); const u = users [ id ]; if ( ! u ) throw httpError ( '사용자가 없습니다' , 404 );
const h = Buffer . from ( hashPassword ( String ( current || '' ), u . salt )); const e = Buffer . from ( u . passHash );
if ( h . length !== e . length || ! crypto . timingSafeEqual ( h , e )) throw httpError ( '현재 비밀번호가 올바르지 않습니다' , 400 );
passpolicy . check ( next , { id , label : '새 비밀번호' });
// v1.5.11: 기본 관리자(admin/admin)가 새 비밀번호를 "admin" 으로 다시 두는 것은 막는다 (규칙상 6자 미만이라 어차피 걸리지만 규칙을 느슨하게 바꾼 경우 대비)
if ( u . mustChangePassword && String ( next ) === BOOTSTRAP_PASSWORD ) throw httpError ( '기본 비밀번호(admin)는 다시 쓸 수 없습니다. 다른 비밀번호를 정하세요' , 400 );
// S05: 세션 세대를 올려 다른 곳에 남아 있는 세션을 끊는다 (본인 화면은 server.js 가 새 세션 쿠키를 다시 준다)
u . salt = crypto . randomBytes ( 16 ). toString ( 'hex' ); u . passHash = hashPassword ( String ( next ), u . salt ); u . passwordChangedAt = new Date (). toISOString (); u . sessionGen = ( u . sessionGen || 0 ) + 1 ; delete u . mustChangePassword ; saveUsers ( users ); return publicUser ( u );
}
/** 관리자용 비밀번호 재설정 (v1.5.11: 초기 설정 페이지의 "관리자 비밀번호 재설정" 도 이것을 쓴다 — 재설정하면 기본 비밀번호 표시가 풀린다) */
function resetPassword ( id , next ) {
const users = loadUsers (); const u = users [ id ]; if ( ! u ) throw httpError ( '사용자가 없습니다' , 404 );
passpolicy . check ( next , { id , label : '새 비밀번호' });
if ( String ( next ) === BOOTSTRAP_PASSWORD ) throw httpError ( '기본 비밀번호(admin)는 쓸 수 없습니다. 다른 비밀번호를 정하세요' , 400 );
u . salt = crypto . randomBytes ( 16 ). toString ( 'hex' ); u . passHash = hashPassword ( String ( next ), u . salt ); u . passwordChangedAt = new Date (). toISOString (); u . sessionGen = ( u . sessionGen || 0 ) + 1 ; delete u . mustChangePassword ; saveUsers ( users ); return publicUser ( u );
}
/** v1.4.0 관리자: 계정 종류 바꾸기 */
function setAccountType ( id , type ) { const users = loadUsers (); if ( ! users [ id ]) throw httpError ( '사용자가 없습니다' , 404 ); if ( ! [ 'subdomain' , 'domain' ]. includes ( type )) throw httpError ( '계정 종류는 subdomain(서브 도메인) 또는 domain(자기 도메인) 입니다' ); users [ id ]. accountType = type ; saveUsers ( users ); return publicUser ( users [ id ]); }
/** v1.5.27 (요구 6) 가입 승인: 대기 표시를 지우고 승인 기록을 남긴다. 거절(reject)은 계정을 지운다 */
function approve ( id , by ) { const users = loadUsers (); const u = users [ id ]; if ( ! u ) throw httpError ( '사용자가 없습니다' , 404 ); if ( ! u . pendingApproval ) return publicUser ( u ); delete u . pendingApproval ; u . approvedAt = new Date (). toISOString (); u . approvedBy = by || null ; saveUsers ( users ); return publicUser ( u ); }
function reject ( id ) { const users = loadUsers (); const u = users [ id ]; if ( ! u ) throw httpError ( '사용자가 없습니다' , 404 ); if ( ! u . pendingApproval ) throw httpError ( '승인 대기 계정이 아닙니다' , 400 ); delete users [ id ]; saveUsers ( users ); return { ok : true , id }; }
function pendingCount () { return Object . values ( loadUsers ()). filter (( u ) => u . pendingApproval ). length ; }
function setRole ( id , role ) { const users = loadUsers (); if ( ! users [ id ]) throw httpError ( '사용자가 없습니다' , 404 ); users [ id ]. role = role === 'admin' ? 'admin' : 'user' ; saveUsers ( users ); return publicUser ( users [ id ]); }
function listUsers () { return Object . values ( loadUsers ()). map ( publicUser ). sort (( a , b ) => a . createdAt . localeCompare ( b . createdAt )); }
function login ({ id , password }) {
id = String ( id || '' ). trim (). toLowerCase (); password = String ( password || '' );
const u = loadUsers ()[ id ];
if ( ! u ) throw httpError ( '아이디 또는 비밀번호가 올바르지 않습니다' , 401 );
const h = Buffer . from ( hashPassword ( password , u . salt )); const e = Buffer . from ( u . passHash );
if ( h . length !== e . length || ! crypto . timingSafeEqual ( h , e )) throw httpError ( '아이디 또는 비밀번호가 올바르지 않습니다' , 401 );
// v1.5.27 (요구 6): 관리자 승인 대기 계정은 비밀번호가 맞아도 들어올 수 없다 (승인되면 pendingApproval 이 지워진다)
if ( u . pendingApproval ) throw Object . assign ( httpError ( '가입 신청이 접수되었습니다. 관리자가 승인하면 로그인할 수 있습니다' , 403 ), { pendingApproval : true });
// v1.5.11: 마지막 로그인 시각 — 설치 점검표가 "운영자가 비밀번호를 아는 관리자 계정이 있는지" 판단하는 데 쓴다
try { const users = loadUsers (); if ( users [ id ]) { users [ id ]. lastLoginAt = new Date (). toISOString (); saveUsers ( users ); } } catch ( _ ) {}
return publicUser ( u );
}
function getUser ( id ) { const u = loadUsers ()[ id ]; return u ? publicUser ( u ) : null ; }
/**
* v1.7.3 추가(운영자 요청): 비밀번호 "지문" — 저장된 해시(salt+passHash)의 sha256 앞 24자. 비밀번호가 바뀌면 달라지고, 이 값으로는 로그인할 수 없다.
* 메일 서버(Arimail) 관리자 비밀번호 동기화(lib/mailsys.syncServiceAdmin)가 "마지막으로 맞춘 비밀번호와 같은지" 를 판단하는 데 쓴다.
*/
function passwordFingerprint ( id ) { const u = loadUsers ()[ String ( id || '' ). toLowerCase ()]; if ( ! u || ! u . passHash ) return null ; return crypto . createHash ( 'sha256' ). update ( ` ${ u . salt || '' } : ${ u . passHash } ` ). digest ( 'hex' ). slice ( 0 , 24 ); }
// ---- 세션 쿠키 (v1.3.17): base64url(JSON {u: 아이디, e: 만료 ms, g: 세션 세대}) + '.' + HMAC ----
// 1.3.16 까지는 "아이디.만료" 를 점으로 나눠 읽어, 점이 든 아이디(victim.probe)는 앞부분을 다른 사용자로, 뒷부분을 만료로 읽었고
// 만료가 숫자가 아니면(NaN) 비교가 통과했다(S01). 이제 구조화된 값만 받고, 만료는 유한한 미래 시각이어야 하며,
// 세대가 사용자 레코드의 sessionGen 과 같아야 한다(S05). 옛 형식 쿠키는 모두 무효 → 다시 로그인.
function sign ( payload ) { return crypto . createHmac ( 'sha256' , SECRET ). update ( payload ). digest ( 'base64url' ); }
/** v1.6.31 (검토 S6): HTTPS 로 들어온 요청(또는 공개 주소가 https)이면 Secure 를 붙여 평문 HTTP 로 쿠키가 새지 않게 한다 */
function makeSessionCookie ( userId , opts = {}) {
const exp = Date . now () + SESSION_DAYS * 86400000 ; const u = loadUsers ()[ userId ];
const payload = Buffer . from ( JSON . stringify ({ v : 2 , u : userId , e : exp , g : ( u && u . sessionGen ) || 0 })). toString ( 'base64url' );
const v = ` ${ payload } . ${ sign ( payload ) } ` ;
return ` ${ COOKIE } = ${ v } ; Path=/; HttpOnly; SameSite=Lax ${ opts . secure ? '; Secure' : '' } ; Max-Age= ${ SESSION_DAYS * 86400 } ` ;
}
function clearSessionCookie () { return ` ${ COOKIE } =; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` ; }
/**
* v1.6.31 (요구 4): CSRF 토큰 — 세션(아이디·세대)에 묶인 HMAC. 빌더·관리자 화면의 fetch 가 X-WM-CSRF 헤더로 보낸다.
* 생성 사이트 페이지(같은 origin)는 이 값을 얻을 수 없다(토큰을 주는 API 는 출처가 빌더 화면일 때만 응답) → Referer 를 없애도 세션을 쓸 수 없다.
*/
function csrfToken ( user ) { const u = loadUsers ()[ user . id ]; return crypto . createHmac ( 'sha256' , SECRET ). update ( `csrf| ${ user . id } | ${ ( u && u . sessionGen ) || 0 } ` ). digest ( 'base64url' ); }
function csrfOk ( user , token ) { if ( ! user || ! token ) return false ; const a = Buffer . from ( csrfToken ( user )); const b = Buffer . from ( String ( token )); return a . length === b . length && crypto . timingSafeEqual ( a , b ); }
function parseCookies ( req ) {
const out = {};
for ( const part of ( req . headers . cookie || '' ). split ( ';' )) { const i = part . indexOf ( '=' ); if ( i > 0 ) out [ part . slice ( 0 , i ). trim ()] = part . slice ( i + 1 ). trim (); }
return out ;
}
/** 요청의 세션 쿠키를 검증해 사용자(공개 정보)를 돌려준다. 없거나 잘못되면 null. */
function userFromRequest ( req ) {
const v = parseCookies ( req )[ COOKIE ];
if ( ! v ) return null ;
const parts = v . split ( '.' ); if ( parts . length !== 2 ) return null ; // 서명 값(base64url)에는 점이 없다 → 정확히 두 조각
const [ payload , sig ] = parts ; if ( ! payload || ! sig ) return null ;
const a = Buffer . from ( sign ( payload )); const b = Buffer . from ( sig );
if ( a . length !== b . length || ! crypto . timingSafeEqual ( a , b )) return null ;
let d ; try { d = JSON . parse ( Buffer . from ( payload , 'base64url' ). toString ( 'utf8' )); } catch ( _ ) { return null ; }
if ( ! d || d . v !== 2 || typeof d . u !== 'string' || ! Number . isFinite ( d . e ) || d . e < Date . now ()) return null ;
const u = loadUsers ()[ d . u ]; if ( ! u ) return null ;
if (( u . sessionGen || 0 ) !== ( Number ( d . g ) || 0 )) return null ; // 비밀번호 변경·재설정 뒤의 옛 세션
return publicUser ( u );
}
/** 프로필 변경(이름/회사명). 사이트의 ownerName 은 다음 저장 때 갱신되지 않으므로 표시용으로만 쓴다 */
function updateProfile ( id , patch ) { const users = loadUsers (); const u = users [ id ]; if ( ! u ) throw Object . assign ( new Error ( '사용자가 없습니다' ), { status : 404 }); if ( patch . name ) u . name = String ( patch . name ). trim (). slice ( 0 , 80 ); saveUsers ( users ); return publicUser ( u ); }
/**
* v1.3.16 사용자의 화면 언어 선택 저장. at 은 고른 시각 — 관리자가 서비스 기본 언어를 "강제" 한 시각(settings.uiLang.forcedAt)보다
* 나중에 고른 경우에만 그 사용자 선택이 이긴다(server.js effectiveUiLang).
*/
function setUiLang ( id , lang ) { const users = loadUsers (); const u = users [ id ]; if ( ! u ) throw httpError ( '사용자가 없습니다' , 404 ); u . uiLang = String ( lang || '' ). slice ( 0 , 20 ); u . uiLangAt = new Date (). toISOString (); saveUsers ( users ); return { uiLang : u . uiLang , uiLangAt : u . uiLangAt }; }
/** 사용자의 화면 언어 선택 (없으면 null) */
function uiLangOf ( id ) { const u = id ? loadUsers ()[ id ] : null ; return u && u . uiLang ? { lang : u . uiLang , at : u . uiLangAt || '' } : null ; }
2026-10-26 11:56:38 +00:00
/**
* [Ariweb Suite 변경] SSO 계정 보장(Ariweb Suite 0.1.1, §0.2 "별도 로그인 없이"). 포털의 서명 티켓이 검증된 뒤 호출된다.
* 아이디가 없으면 무작위 비밀번호(아무도 모름 — 포털 SSO 로만 들어온다)로 만들고, 있으면 이름만 맞춘다. 역할은 user, 계정 종류는 domain(포털이 도메인을 관리).
* 비밀번호 규칙·가입 승인·가입 허용 설정을 거치지 않는다: 이 계정은 사람이 가입한 것이 아니라 포털 사용자와 1:1 로 대응하는 내부 계정이기 때문이다.
*/
function ensureSsoUser ( id , name ) {
id = String ( id || '' ). trim (). toLowerCase (); if ( ! ID_RE . test ( id )) throw httpError ( 'SSO 아이디 형식이 올바르지 않습니다' );
const users = loadUsers ();
if ( ! users [ id ]) { const salt = crypto . randomBytes ( 16 ). toString ( 'hex' ); users [ id ] = { id , name : String ( name || id ). trim (). slice ( 0 , 80 ) || id , salt , passHash : hashPassword ( crypto . randomBytes ( 24 ). toString ( 'base64url' ), salt ), role : 'user' , accountType : 'domain' , createdAt : new Date (). toISOString (), sso : 'ariweb' }; }
else if ( name && users [ id ]. name !== name ) users [ id ]. name = String ( name ). trim (). slice ( 0 , 80 );
users [ id ]. lastLoginAt = new Date (). toISOString (); saveUsers ( users ); return publicUser ( users [ id ]);
}
module . exports = { ensureSsoUser , passwordFingerprint , csrfToken , csrfOk , approve , reject , pendingCount , setAccountType , setUiLang , uiLangOf , parseCookies , updateProfile , signup , login , getUser , createUser , setRole , listUsers , changePassword , resetPassword , makeSessionCookie , clearSessionCookie , userFromRequest , bootstrapAdmin , ensureBootstrapAdmin , adminState , BOOTSTRAP_ID , BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태