SMHUB
메일·메신저(XMPP)·ActiveSync(SmartSync)·WebDAV 파일공유·CalDAV/CardDAV·LDAP/AD를 하나의 계정/토큰으로 묶는 OAuth2 / OpenID Connect 인증 허브입니다. 지금 이 서버에서는 auth.freeitm.com으로 운영 중이며, 관리 화면은 Test Center 관리자 > SMHUB 클라이언트에서 등록된 클라이언트를 확인/추가/삭제할 수 있습니다. 이 페이지는 SMHUB를 새 서버에 그대로 옮겨 설치하기 위한 문서입니다.
구성도
SMHUB 자체는 작은 Node.js 프로세스 하나지만, 실제로 의미가 있으려면 아래 4가지가 모두 있어야 합니다. 앞의 둘(Postgres/Redis)은 필수, 뒤의 셋(IMAP/ActiveSync/XMPP·WebDAV)은 "그 서비스를 SMHUB로 인증시키고 싶을 때만" 필요합니다.
SMHUB (이 이미지)
- OAuth2 / OIDC Provider (oidc-provider)
- Authorization Code + PKCE
- RFC 7662 Token Introspection
- 가입신청 / 비밀번호 재설정 셀프서비스
필수: Postgres
- 계정/도메인/OIDC 클라이언트 저장소
- SMHUB는 스키마를 만들지 않음 - 아래 "DB 스키마" 그대로 미리 생성 필요
필수: Redis
- 로그인 세션 / 인가코드 / 토큰 저장
- 비어있는 인스턴스면 충분(자동 채워짐)
선택: 비밀번호 검증 백엔드
- local 도메인 → IMAP(Dovecot) 로그인 시도로 검증
- smartermail 도메인 → SmarterMail REST API로 검증
- ldap 도메인 → LDAP/Active Directory bind로 검증
선택: XMPP(ejabberd) / WebDAV / ActiveSync(SmartSync) / CalDAV·CardDAV
- 각자 SMHUB의 introspection 엔드포인트를 호출해 토큰 로그인 지원
- 연동 안 해도 SMHUB 자체(OIDC 로그인)는 정상 동작
- CalDAV/CardDAV는 ActiveSync와 같은 calendar_events/contacts 테이블을 그대로 공유
프로토콜/표준 엔드포인트(/auth,
/token, /jwks 등)의 상세 설명은
인증 서비스 소개 페이지를 참고하세요. 이 페이지는 설치/설정에 집중합니다.
다운로드
Docker 이미지(gzip tar, 약 115MB) 하나에 SMHUB 코어(Node.js)와 CalDAV/CardDAV(Radicale,
Python)가 함께 들어있습니다 - 별도 이미지를 따로 받을 필요가 없습니다. docker load로
바로 사용할 수 있고, CalDAV/CardDAV는 관련 환경변수(아래 참고)를 넣지 않으면 자동으로
비활성 상태로 남습니다. 이미지 안에는 서명키(jwks)가 들어있지 않습니다 - 컨테이너를 처음
띄우면 자동으로 새로 만들어지므로, 배포마다 서로 다른 서버가 같은 서명키를 공유하는
일이 없습니다.
CalDAV/CardDAV는 위 핵심 스키마에 없는 별도 테이블(캘린더/연락처 데이터)이 필요합니다. 대상 Postgres에 아래 SQL을 추가로 실행하세요.
CREATE TABLE calendar_folders ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL REFERENCES mailboxes(username) ON DELETE CASCADE, name TEXT NOT NULL, color VARCHAR(20) DEFAULT '#4285f4', created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE calendar_events ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL REFERENCES mailboxes(username) ON DELETE CASCADE, folder_id INTEGER REFERENCES calendar_folders(id) ON DELETE CASCADE, title TEXT DEFAULT '', start_at TIMESTAMPTZ, end_at TIMESTAMPTZ, all_day BOOLEAN DEFAULT false, location TEXT DEFAULT '', description TEXT DEFAULT '', caldav_href TEXT, -- 클라이언트가 PUT할 때 스스로 지은 파일명(예: UUID.ics) 보존용 caldav_uid TEXT, -- iCalendar UID 보존용(재생성하면 클라이언트 동기화가 깨짐) created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); CREATE UNIQUE INDEX idx_calendar_events_caldav_href ON calendar_events(folder_id, caldav_href) WHERE caldav_href IS NOT NULL; CREATE TABLE contact_folders ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL REFERENCES mailboxes(username) ON DELETE CASCADE, name TEXT NOT NULL, is_shared BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE contacts ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL REFERENCES mailboxes(username) ON DELETE CASCADE, folder_id INTEGER REFERENCES contact_folders(id) ON DELETE CASCADE, display_name TEXT, full_name TEXT, first_name TEXT, last_name TEXT, email TEXT, email2 TEXT, phone TEXT, mobile TEXT, company TEXT, job_title TEXT, department TEXT, address_work TEXT, address_home TEXT, notes TEXT, photo_path TEXT, carddav_href TEXT, carddav_uid TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX idx_contacts_carddav_href ON contacts(folder_id, carddav_href) WHERE carddav_href IS NOT NULL;
이 테이블들이 이미 있는(예: 기존 ActiveSync를 쓰던) DB라면 CREATE TABLE
대신 caldav_href/caldav_uid/
carddav_href/carddav_uid 4개 컬럼만
ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...로 추가하면 됩니다 -
CalDAV/CardDAV가 없던 시절 만들어진 이벤트/연락처는 이 컬럼들이 비어있어도 자동으로
event-{id}.ics 형태 href로 노출됩니다.
설치 방법
1) DB 스키마 준비
SMHUB는 signup_requests, password_reset_tokens
두 테이블만 첫 실행 시 자동으로 만듭니다. 나머지(계정/도메인/OIDC 클라이언트 등)는
미리 존재해야 합니다 - 아래 SQL을 대상 Postgres에 실행하세요(이미 메일서버용 계정
DB가 있다면 이 스키마와 호환되는지 먼저 확인).
CREATE TABLE domains (
domain VARCHAR(255) PRIMARY KEY,
active SMALLINT NOT NULL DEFAULT 1,
quota_mb INTEGER NOT NULL DEFAULT 1024,
storage_path VARCHAR(255),
zpush_allowed BOOLEAN NOT NULL DEFAULT true,
archive_enabled BOOLEAN NOT NULL DEFAULT false,
archive_retention_days INTEGER NOT NULL DEFAULT 365,
auth_type VARCHAR(50) NOT NULL DEFAULT 'local', -- 'local' | 'smartermail' | 'ldap'
auth_config JSONB NOT NULL DEFAULT '{}' -- smartermail: {"api_url":"...","verify_ssl":true}
-- ldap: {"url":"...","bind_dn":"...","bind_password":"...",
-- "base_dn":"...","user_filter":"...","admin_group_dn":"..."}
);
CREATE TABLE roles (
role_id SERIAL PRIMARY KEY,
role_name VARCHAR(255) NOT NULL UNIQUE,
description TEXT
);
CREATE TABLE mailboxes (
username VARCHAR(255) PRIMARY KEY, -- 이메일 주소 그대로 (JID/로그인 ID로도 재사용됨)
domain VARCHAR(255) NOT NULL REFERENCES domains(domain),
password VARCHAR(255) NOT NULL, -- IMAP(Dovecot)이 검증하므로 SMHUB는 이 컬럼을 직접 비교하지 않음
name VARCHAR(255) NOT NULL DEFAULT '',
maildir VARCHAR(255) NOT NULL DEFAULT '',
quota_mb BIGINT NOT NULL DEFAULT 0,
quota_bytes BIGINT NOT NULL DEFAULT 1073741824,
active SMALLINT NOT NULL DEFAULT 1,
is_admin BOOLEAN NOT NULL DEFAULT false,
contact_email VARCHAR(255) DEFAULT '',
phone VARCHAR(50) DEFAULT '',
mobile VARCHAR(50) DEFAULT '',
position VARCHAR(255) DEFAULT '',
address VARCHAR(255) DEFAULT '',
anniversary DATE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
role_id INTEGER REFERENCES roles(role_id)
);
CREATE TABLE departments (
department_id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES departments(department_id) ON DELETE SET NULL,
domain VARCHAR(255) REFERENCES domains(domain),
UNIQUE (domain, name)
);
CREATE TABLE groups (
group_id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL REFERENCES domains(domain),
name VARCHAR(255) NOT NULL,
description TEXT,
UNIQUE (domain, name)
);
CREATE TABLE mailbox_departments (
username VARCHAR(255) NOT NULL REFERENCES mailboxes(username) ON DELETE CASCADE,
department_id INTEGER NOT NULL REFERENCES departments(department_id) ON DELETE CASCADE,
PRIMARY KEY (username, department_id)
);
CREATE TABLE mailbox_groups (
username VARCHAR(255) NOT NULL REFERENCES mailboxes(username) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
PRIMARY KEY (username, group_id)
);
CREATE TABLE auth_oidc_clients (
client_id VARCHAR(100) PRIMARY KEY,
client_secret VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
grant_types TEXT[] NOT NULL DEFAULT '{authorization_code}',
response_types TEXT[] NOT NULL DEFAULT '{code}',
scopes TEXT[] NOT NULL DEFAULT '{openid,email,profile,mail}',
token_endpoint_auth_method VARCHAR(50) NOT NULL DEFAULT 'client_secret_basic',
is_first_party BOOLEAN NOT NULL DEFAULT true,
allow_signup BOOLEAN NOT NULL DEFAULT true,
post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 가입 신청/비밀번호 정책 화면과 공유하는 정책 테이블(없어도 SMHUB 자체 로그인은 동작하지만,
-- 셀프서비스 비밀번호 재설정 기능이 이 테이블을 조회함)
CREATE TABLE password_policy (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
min_length INTEGER NOT NULL DEFAULT 8,
require_number BOOLEAN NOT NULL DEFAULT true,
require_capital BOOLEAN NOT NULL DEFAULT true,
require_lowercase BOOLEAN NOT NULL DEFAULT true,
require_symbol BOOLEAN NOT NULL DEFAULT false,
no_username_match BOOLEAN NOT NULL DEFAULT true,
prevent_common BOOLEAN NOT NULL DEFAULT true,
prevent_reuse BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO password_policy (id) VALUES (1);
2) 컨테이너 실행
docker load < smhub-image.tar.gz docker run -d --name smhub --restart unless-stopped \ -p 8092:8092 \ -v /opt/smhub/data:/app/data \ -e PORT=8092 \ -e ISSUER=https://auth.your-domain.example \ -e COOKIE_KEYS=$(openssl rand -hex 32) \ -e PG_HOST=127.0.0.1 -e PG_PORT=5432 -e PG_DATABASE=sm_ent \ -e PG_USER=<DB사용자> -e PG_PASSWORD=<DB비밀번호> \ -e REDIS_HOST=127.0.0.1 -e REDIS_PORT=6379 -e REDIS_PASSWORD=<레디스비밀번호> \ -e IMAP_HOST=127.0.0.1 -e IMAP_PORT=993 -e IMAP_TLS_SERVERNAME=mail.your-domain.example \ -e SMHUB_INTROSPECT_CLIENT_SECRET=<caldav-introspect 클라이언트 시크릿, 선택> \ -e CALDAV_DOMAIN=your-domain.example \ -p 5232:5232 \ smhub:latest
SMHUB_INTROSPECT_CLIENT_SECRET이 비어있으면 CalDAV/CardDAV(Radicale)는
그냥 시작하지 않습니다(이미지에는 항상 포함되어 있지만 선택 기능) - 필요할 때만 이
두 값을 채우고 5232 포트도 함께 노출하세요.
-v /opt/smhub/data:/app/data는 반드시 볼륨으로 유지하세요.
여기에 자동 생성되는 서명키(jwks.json)가 들어가는데, 이 파일이 사라지면 이미 로그인한
사용자들의 세션/토큰 서명검증이 전부 깨집니다(재로그인은 필요하지만 서비스 자체가
멈추진 않습니다).
설정 방법 (환경변수)
| 변수 | 기본값 | 설명 |
|---|---|---|
| PORT | 8092 | SMHUB가 수신할 포트 |
| ISSUER | - | 이 SMHUB 인스턴스의 공개 URL(https). 발급하는 모든 토큰에 박히므로 배포 후 바꾸면 안 됨 |
| COOKIE_KEYS | (안전하지 않은 기본값) | 세션 쿠키 서명키, 콤마로 여러 개 가능. 반드시 직접 지정 |
| JWKS_PATH | /app/data/jwks.json | 자동 생성/재사용되는 서명키 파일 경로. 볼륨 마운트 필수 |
| PG_HOST / PG_PORT / PG_DATABASE / PG_USER / PG_PASSWORD | 127.0.0.1:5432 / sm_ent / mailsystem | 위 스키마가 있는 Postgres 접속 정보 |
| REDIS_HOST / REDIS_PORT / REDIS_PASSWORD | 127.0.0.1:6379 | 세션/토큰 저장용 Redis (빈 인스턴스면 충분) |
| IMAP_HOST / IMAP_PORT | 127.0.0.1:993 | auth_type='local' 도메인 비밀번호 검증에 쓰는 IMAP 서버 |
| IMAP_TLS_SERVERNAME | IMAP_HOST 값 | IMAP 서버 TLS 인증서의 CN/SAN에 맞는 도메인명(보통 mail.your-domain.example) |
| IMAP_TLS_REJECT_UNAUTHORIZED | false | true로 하면 IMAP 서버 인증서를 정식 검증(신뢰된 CA 발급 인증서가 있을 때만 켜세요) |
| MAIL_FROM | postmaster@localhost | 가입신청 알림/비밀번호 재설정 메일의 발신 주소 |
| SMTP_HOST / SMTP_PORT | 127.0.0.1:25 | 시스템 메일을 내보낼 SMTP 릴레이 |
| ADMIN_NOTIFY_EMAIL | (미설정 시 발송 안 함) | 새 가입 신청이 들어왔을 때 알림을 받을 관리자 주소 |
| BRAND_NAME | SMHUB | 시스템 메일 제목에 쓰이는 브랜드명 |
| XMPP_HOSTS | (미설정 시 XMPP 연동 안 함) | ejabberd가 서빙하는 XMPP vhost 도메인 목록(콤마 구분). ejabberd.yml의 hosts와 일치해야 함 |
| EJABBERD_API_URL | http://127.0.0.1:5280/api | 역할/부서/그룹→XMPP 연락처 자동 동기화가 호출하는 ejabberd 관리 API |
| MESSENGER_INTROSPECT_SECRET | (미설정 시 XMPP 토큰 로그인 비활성) | 아래 "OIDC 클라이언트 등록"의 messenger-introspect 클라이언트 시크릿 |
| MESSENGER_INTROSPECT_CLIENT_ID | messenger-introspect | 위 시크릿이 속한 client_id (다른 이름을 썼다면 함께 변경) |
| XMPP_AUTH_PORT | 8097 | ejabberd extauth 스크립트가 호출하는 내부 전용 포트(127.0.0.1 바인딩, 외부 노출 금지) |
| SMHUB_INTROSPECT_CLIENT_SECRET | (미설정 시 CalDAV/CardDAV 비활성) | caldav-introspect 클라이언트 시크릿 - 이미지에 포함된 Radicale 기반 CalDAV/CardDAV를 켤지 여부를 이 값의 유무로 결정 |
| CALDAV_DOMAIN | freeitm.com | CalDAV/CardDAV 클라이언트가 도메인 없이 로그인 아이디만 보낼 때 붙일 기본 도메인 |
OIDC 클라이언트 등록
SMHUB에 로그인을 위임할 서비스(웹메일, ActiveSync, WebDAV, XMPP, 다른 웹앱 등)마다
auth_oidc_clients에 행이 하나씩 있어야 합니다. 이 서버에서는
Test Center 관리자 > SMHUB 클라이언트
화면에서 등록/삭제하지만, 신규 서버는 그 관리 화면이 아직 없다면 SQL로 직접 등록하세요.
-- 브라우저 로그인(Authorization Code + PKCE)이 필요한 웹앱
INSERT INTO auth_oidc_clients (client_id, client_secret, name, redirect_uris, post_logout_redirect_uris)
VALUES ('webmail', '<랜덤 시크릿>', 'Webmail', '{https://mail.your-domain.example/api/v1/auth/callback}', '{https://mail.your-domain.example/login}');
-- 토큰 introspection만 호출하는 서비스(ActiveSync/XMPP/WebDAV 등) - redirect_uri 불필요
INSERT INTO auth_oidc_clients (client_id, client_secret, name, redirect_uris, grant_types, response_types)
VALUES ('messenger-introspect', '<랜덤 시크릿>', 'XMPP token introspection', '{}', '{}', '{}');
각 서비스 연동 방법 요약
| 서비스 | 필요한 것 |
|---|---|
| 웹 애플리케이션(자체 로그인화면 없이 위임) | OIDC_ISSUER=SMHUB 주소, 발급받은 client_id/secret으로
/auth → /token Authorization Code
플로우 수행. 자세한 흐름은 /auth 참고. |
| IMAP/POP3/SMTP (Dovecot) | Dovecot에 oauth2 passdb 추가, introspection_url을
https://client_id:secret@SMHUB주소/token/introspection로 지정하면
비밀번호 로그인과 별개로 OAuth 토큰 로그인(XOAUTH2/OAUTHBEARER)도 지원됨. |
| ActiveSync (SmartSync) | 모바일/Outlook용 ActiveSync는 Python으로 자체 구현한 SmartSync를
씁니다(core/auth.py) - PHP 기반 Z-Push는 SMHUB
제품군에서 완전히 제외했습니다(보안상 PHP 의존을 없애기 위함). 메일/캘린더/
연락처/메모/작업 5종을 전부 지원하며, Basic Auth 비밀번호 자리로 온 값을 먼저
introspection으로 검증 → 유효하면 Dovecot master user로 로그인, 실패하면 기존
실제 비밀번호 로그인으로 그대로 폴백합니다. SmartSync도 별도 Docker 이미지로
배포하며, 상태를 전부 Postgres에 저장하는 stateless 구조라 여러 대를 로드밸런서
뒤에 sticky session 없이 띄워도 안전합니다. |
| XMPP(ejabberd) | auth_method: external로 설정하고, extauth 스크립트가
SMHUB의 XMPP_AUTH_PORT(/check,
/isuser)를 호출하도록 구성. ejabberd와 SMHUB가 같은
네트워크(127.0.0.1로 서로 접근 가능)에 있어야 함. |
| WebDAV | Basic Auth 검증 단계에서 비밀번호 자리 값을 introspection으로 먼저 확인, 실패하면 IMAP 비밀번호 로그인으로 폴백 - SmartSync와 동일한 패턴. |
| CalDAV/CardDAV | Radicale(오픈소스 CalDAV/CardDAV 서버)에 커스텀 인증/저장소 플러그인을 붙여
ActiveSync가 쓰는 calendar_events/contacts
테이블을 그대로 읽고 씁니다 - 별도 저장소가 아니라 "같은 데이터를 보는 또 하나의
창구"입니다. 인증은 introspection 우선, 실패 시 SMHUB의 내부 /check
엔드포인트(도메인별 local/smartermail/ldap 라우팅을 그대로 태움)로 폴백. 클라이언트는
https://your-domain.example/dav/<이메일>/로 접속. |
LDAP/Active Directory 연동
도메인의 auth_type을 ldap로 설정하면,
SMHUB가 IMAP 대신 LDAP bind로 비밀번호를 검증합니다. 표준 "서비스 계정으로 먼저 bind →
검색 → 찾은 DN으로 실제 사용자 비밀번호로 재bind" 방식이라 조직마다 제각각인 DN 구조에도
안전하게 대응합니다. 이 서버에서는
Test Center 관리자 > LDAP/AD 연동
화면에서 도메인별로 설정하고 "연결 테스트" 버튼으로 바로 검증할 수 있습니다.
| 설정값 | 설명 |
|---|---|
| 서버 URL | 예: ldaps://dc1.corp.example.com:636 |
| 서비스 계정 DN / 비밀번호 | 검색 전용 계정 - 실제 로그인 검증에는 안 쓰임 |
| 검색 기준 DN (base DN) | 사용자를 찾을 하위 트리 |
| 사용자 검색 필터 | AD 예: (&(objectClass=user)(sAMAccountName={user})).
{user}=이메일 @ 앞부분, {email}=전체 이메일 |
| 관리자 그룹 DN (선택) | 이 그룹의 memberOf를 가진 사용자를 관리자로 인식 |
라이브 데모
실제로 위 구성 전체(웹메일 + ActiveSync + XMPP + WebDAV + CalDAV/CardDAV + LDAP/AD/M365)가 연동되어 운영 중인 인스턴스를 auth.freeitm.com에서 확인할 수 있습니다. 실측 성능/보안 리포트는 여기서 확인하고 직접 다시 실행해볼 수 있습니다.