From 1aeaf6aee5e16f46b1ec5c0eb811fe1dcfb1d313 Mon Sep 17 00:00:00 2001 From: Tamir Suliman Date: Sun, 17 May 2026 11:52:23 +0200 Subject: [PATCH] Add Keycloak OIDC provider support --- .env.example | 11 + README.md | 103 ++----- config/config.docker.php | 9 + config/config.php | 118 +++---- docker-compose.yml | 6 + docker/init-db.sql | 16 + schema.sql | 89 +++--- src/Auth.php | 340 ++++++++++++++------- src/OAuthP.php | 382 +++++++++++++---------- templates/landing.php | 641 ++++++++++++++++++++------------------- 10 files changed, 958 insertions(+), 757 deletions(-) diff --git a/.env.example b/.env.example index af806c7..11dd513 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,14 @@ MICROSOFT_CLIENT_SECRET= # Leave blank to disable this provider. https://github.com/settings/developers GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= + +# ── OAuth/OIDC: Keycloak ───────────────────────────────────────────────────── +# Leave blank to disable this provider. +# Local callback URL: http://localhost:8080/auth/callback/keycloak +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_BASE_URL= +KEYCLOAK_REALM= +# Optional override; defaults to APP_URL + /auth/callback/keycloak. +KEYCLOAK_REDIRECT_URI= +KEYCLOAK_SCOPE=openid email profile diff --git a/README.md b/README.md index 212daa7..6369b5c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A self-hosted TOTP (Time-based One-Time Password) manager built with PHP. Secret - **Server-side code generation** — secrets are decrypted in memory only at generation time and never sent to the browser - **AES-256-GCM encryption** — every secret is encrypted at rest with a unique 96-bit nonce per record -- **Multiple login methods** — OAuth 2.0 via Google, Microsoft, or GitHub; passwordless magic links via email +- **Multiple login methods** — OAuth 2.0/OIDC via Google, Microsoft, GitHub, or Keycloak; passwordless magic links via email - **Token sharing** — share individual OTP profiles with colleagues by email; they can view codes (or optionally edit settings) without ever seeing the secret - **QR code import** — paste or drag an `otpauth://` QR image directly in the browser to auto-fill all token fields - **Hide mode** — mask a token's code until clicked; it reveals for 10 seconds then hides itself again @@ -140,6 +140,15 @@ return [ 'userinfo_url' => 'https://api.github.com/user', ], + 'keycloak' => [ + 'client_id' => '', + 'client_secret' => '', + 'base_url' => 'https://keycloak.example.com', + 'realm' => 'totpvault', + 'redirect_uri' => 'https://yourdomain.com/auth/callback/keycloak', + 'scope' => 'openid email profile', + ], + ], ]; ``` @@ -161,15 +170,16 @@ No password required. The user enters their email address and receives a time-li ### OAuth 2.0 -One-click sign-in via a third-party identity provider. The OAuth state parameter is verified on callback to prevent CSRF. Three providers are supported out of the box: +One-click sign-in via a third-party identity provider. The OAuth state parameter is verified on callback to prevent CSRF. Four providers are supported out of the box: | Provider | Notes | |---|---| | **Google** | OpenID Connect; fetches name, email, and profile picture | | **Microsoft** | Microsoft Graph; supports personal and work/school accounts | | **GitHub** | Fetches the primary verified email separately via `/user/emails` since the profile endpoint may return `null` | +| **Keycloak** | OpenID Connect discovery; maps `sub`, `email`, `name` / `preferred_username`, and optional `picture` claims | -Each provider stores its own `provider_id` column in the `users` table. A user who signs in with Google and later via magic link to the same email address shares the same account row. +OAuth identities are stored in the `oauth_identities` table as `(provider, provider_id)` pairs linked to `users.id`. A user who signs in with Google and later via magic link to the same email address shares the same account row. --- @@ -309,77 +319,17 @@ If the invited email does not yet have an account, the share is stored as pendin ## Database schema -```sql +The canonical schema lives in `schema.sql`. Docker deployments use `docker/init-db.sql` on first database startup. --- --- Table structure for table `magic_links` --- +Core tables: -CREATE TABLE `magic_links` ( - `id` int(11) NOT NULL, - `email` varchar(255) NOT NULL, - `token_hash` varchar(64) NOT NULL, - `used` tinyint(1) NOT NULL DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- -------------------------------------------------------- - --- --- Table structure for table `otp_profiles` --- - -CREATE TABLE `otp_profiles` ( - `id` int(11) NOT NULL, - `user_id` int(11) NOT NULL, - `name` varchar(255) NOT NULL, - `issuer` varchar(255) DEFAULT '', - `secret_encrypted` text NOT NULL COMMENT 'AES-256-GCM encrypted base32 secret', - `algorithm` enum('SHA1','SHA256','SHA512') DEFAULT 'SHA1', - `digits` tinyint(4) DEFAULT 6 COMMENT '6, 8 or 10', - `period` smallint(6) DEFAULT 30 COMMENT 'seconds', - `color` varchar(7) DEFAULT '#6366f1', - `icon` varchar(50) DEFAULT 'shield', - `hide_code` tinyint(1) NOT NULL DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- -------------------------------------------------------- - --- --- Table structure for table `profile_shares` --- - -CREATE TABLE `profile_shares` ( - `id` int(11) NOT NULL, - `profile_id` int(11) NOT NULL, - `shared_by_user_id` int(11) NOT NULL, - `shared_with_email` varchar(255) NOT NULL, - `shared_with_user_id` int(11) DEFAULT NULL, - `can_edit` tinyint(1) DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- -------------------------------------------------------- - --- --- Table structure for table `users` --- - -CREATE TABLE `users` ( - `id` int(11) NOT NULL, - `email` varchar(255) NOT NULL, - `name` varchar(255) DEFAULT NULL, - `avatar_url` varchar(500) DEFAULT NULL, - `google_id` varchar(255) DEFAULT NULL, - `microsoft_id` varchar(255) DEFAULT NULL, - `github_id` varchar(255) DEFAULT NULL, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8; -``` +| Table | Purpose | +|---|---| +| `users` | Application account profile keyed by email | +| `oauth_identities` | Generic OAuth/OIDC identities keyed by `(provider, provider_id)` and linked to `users.id` | +| `magic_links` | Hashed passwordless login tokens | +| `otp_profiles` | Encrypted TOTP profiles | +| `profile_shares` | Token sharing grants and pending shares | --- @@ -395,7 +345,7 @@ CREATE TABLE `users` ( │ ├── Crypto.php # AES-256-GCM encrypt / decrypt │ ├── Database.php # PDO singleton │ ├── MagicLink.php # Passwordless email login -│ ├── OAuthP.php # OAuth 2.0 — Google / Microsoft / GitHub +│ ├── OAuthP.php # OAuth 2.0/OIDC — Google / Microsoft / GitHub / Keycloak │ ├── Profile.php # TOTP profile CRUD and sharing │ └── TOTP.php # RFC 6238 code generation and Base32 ├── templates/ @@ -437,7 +387,7 @@ open http://localhost:8080 > - `DB_PASSWORD` — use a strong unique password > - `DB_ROOT_PASSWORD` — use a strong unique password -To sign in, configure at least one login method in `.env`: MailerSend for magic links, or Google, Microsoft, or GitHub OAuth credentials. +To sign in, configure at least one login method in `.env`: MailerSend for magic links, or Google, Microsoft, GitHub, or Keycloak OAuth/OIDC credentials. ### Makefile shortcuts @@ -482,7 +432,7 @@ To list volumes: `docker volume ls | grep totpvault` ### OAuth provider setup -Google, Microsoft, and GitHub sign-in require OAuth application credentials from each provider. For local Docker deployment, keep: +Google, Microsoft, GitHub, and Keycloak sign-in require OAuth/OIDC application credentials from each provider. For local Docker deployment, keep: ```env APP_URL=http://localhost:8080 @@ -495,6 +445,9 @@ Then register these callback URLs with the providers you want to enable: | Google | [Google Cloud Console — Credentials](https://console.cloud.google.com/apis/credentials) | `http://localhost:8080/auth/callback/google` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | | Microsoft | [Microsoft Entra — App registrations](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) | `http://localhost:8080/auth/callback/microsoft` | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` | | GitHub | [GitHub Developer Settings — OAuth Apps](https://github.com/settings/developers) | `http://localhost:8080/auth/callback/github` | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | +| Keycloak | Your Keycloak admin console | `http://localhost:8080/auth/callback/keycloak` | `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `KEYCLOAK_BASE_URL`, `KEYCLOAK_REALM` | + +For Keycloak, create an OpenID Connect client in your realm, set the valid redirect URI to the callback URL above, and set `KEYCLOAK_BASE_URL` to the Keycloak server root, for example `https://sso.example.com`. TOTPVault uses `KEYCLOAK_BASE_URL` and `KEYCLOAK_REALM` to read the realm's `/.well-known/openid-configuration` discovery document. After updating `.env`, rebuild/restart the app: diff --git a/config/config.docker.php b/config/config.docker.php index 785859f..7375534 100644 --- a/config/config.docker.php +++ b/config/config.docker.php @@ -80,5 +80,14 @@ return [ 'scope' => 'read:user user:email', ], + 'keycloak' => [ + 'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '', + 'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '', + 'base_url' => rtrim(getenv('KEYCLOAK_BASE_URL') ?: '', '/'), + 'realm' => getenv('KEYCLOAK_REALM') ?: '', + 'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: ($appUrl . '/auth/callback/keycloak'), + 'scope' => getenv('KEYCLOAK_SCOPE') ?: 'openid email profile', + ], + ], ]; diff --git a/config/config.php b/config/config.php index 49bc1b8..a1b5ee1 100644 --- a/config/config.php +++ b/config/config.php @@ -1,55 +1,63 @@ - [ - 'host' => getenv('DB_HOST') ?: 'localhost', - 'port' => getenv('DB_PORT') ?: 3306, - 'dbname' => getenv('DB_NAME') ?: 'totp', - 'user' => getenv('DB_USER') ?: 'user', - 'password' => getenv('DB_PASSWORD') ?: 'password', - 'charset' => 'utf8mb4', - ], - 'encryption_key' => getenv('ENCRYPTION_KEY') ?: 'ThisIsA32ByteKeyForLocalTesting!', //Change this! - 'app_name' => 'TOTPVault', - 'app_url' => getenv('APP_URL') ?: 'https://totp.token2.swiss', - 'app_env' => getenv('APP_ENV') ?: 'production', - 'mail' => [ - 'from_email' => getenv('MAIL_FROM') ?: 'noreply@totp.token2.swiss', - 'from_name' => getenv('MAIL_FROM_NAME') ?: 'TOTPVault', - 'mailersend_key' => getenv('MAILERSEND_KEY') ?: 'mlsn.xxxx', - ], - 'oauth' => [ - 'google' => [ - 'client_id' => getenv('GOOGLE_CLIENT_ID') ?: '', - 'client_secret' => getenv('GOOGLE_CLIENT_SECRET') ?: '', - 'redirect_uri' => (getenv('APP_URL') ?: 'https://totp.token2.swiss') . '/auth/callback/google', - 'auth_url' => 'https://accounts.google.com/o/oauth2/v2/auth', - 'token_url' => 'https://oauth2.googleapis.com/token', - 'userinfo_url' => 'https://www.googleapis.com/oauth2/v3/userinfo', - 'scope' => 'openid email profile', - ], - 'microsoft' => [ - 'client_id' => getenv('MICROSOFT_CLIENT_ID') ?: '', - 'client_secret' => getenv('MICROSOFT_CLIENT_SECRET') ?: '', - 'redirect_uri' => (getenv('APP_URL') ?: 'https://totp.token2.swiss') . '/auth/callback/microsoft', - 'auth_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - 'token_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - 'userinfo_url' => 'https://graph.microsoft.com/v1.0/me', - 'scope' => 'openid email profile User.Read', - ], - 'github' => [ - 'client_id' => getenv('GITHUB_CLIENT_ID') ?: '', - 'client_secret' => getenv('GITHUB_CLIENT_SECRET') ?: '', - 'redirect_uri' => (getenv('APP_URL') ?: 'https://totp.token2.swiss') . '/auth/callback/github', - 'auth_url' => 'https://github.com/login/oauth/authorize', - 'token_url' => 'https://github.com/login/oauth/access_token', - 'userinfo_url' => 'https://api.github.com/user', - 'scope' => 'read:user user:email', - ], - ], - 'session' => [ - 'lifetime' => 86400 * 30, - 'cookie_name' => 'authvault_session', - ], -]; + [ + 'host' => getenv('DB_HOST') ?: 'localhost', + 'port' => getenv('DB_PORT') ?: 3306, + 'dbname' => getenv('DB_NAME') ?: 'totp', + 'user' => getenv('DB_USER') ?: 'user', + 'password' => getenv('DB_PASSWORD') ?: 'password', + 'charset' => 'utf8mb4', + ], + 'encryption_key' => getenv('ENCRYPTION_KEY') ?: 'ThisIsA32ByteKeyForLocalTesting!', //Change this! + 'app_name' => 'TOTPVault', + 'app_url' => rtrim(getenv('APP_URL') ?: 'https://totp.token2.swiss', '/'), + 'app_env' => getenv('APP_ENV') ?: 'production', + 'mail' => [ + 'from_email' => getenv('MAIL_FROM') ?: 'noreply@totp.token2.swiss', + 'from_name' => getenv('MAIL_FROM_NAME') ?: 'TOTPVault', + 'mailersend_key' => getenv('MAILERSEND_KEY') ?: 'mlsn.xxxx', + ], + 'oauth' => [ + 'google' => [ + 'client_id' => getenv('GOOGLE_CLIENT_ID') ?: '', + 'client_secret' => getenv('GOOGLE_CLIENT_SECRET') ?: '', + 'redirect_uri' => (getenv('APP_URL') ?: 'https://totp.token2.swiss') . '/auth/callback/google', + 'auth_url' => 'https://accounts.google.com/o/oauth2/v2/auth', + 'token_url' => 'https://oauth2.googleapis.com/token', + 'userinfo_url' => 'https://www.googleapis.com/oauth2/v3/userinfo', + 'scope' => 'openid email profile', + ], + 'microsoft' => [ + 'client_id' => getenv('MICROSOFT_CLIENT_ID') ?: '', + 'client_secret' => getenv('MICROSOFT_CLIENT_SECRET') ?: '', + 'redirect_uri' => (getenv('APP_URL') ?: 'https://totp.token2.swiss') . '/auth/callback/microsoft', + 'auth_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + 'token_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + 'userinfo_url' => 'https://graph.microsoft.com/v1.0/me', + 'scope' => 'openid email profile User.Read', + ], + 'github' => [ + 'client_id' => getenv('GITHUB_CLIENT_ID') ?: '', + 'client_secret' => getenv('GITHUB_CLIENT_SECRET') ?: '', + 'redirect_uri' => (getenv('APP_URL') ?: 'https://totp.token2.swiss') . '/auth/callback/github', + 'auth_url' => 'https://github.com/login/oauth/authorize', + 'token_url' => 'https://github.com/login/oauth/access_token', + 'userinfo_url' => 'https://api.github.com/user', + 'scope' => 'read:user user:email', + ], + 'keycloak' => [ + 'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '', + 'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '', + 'base_url' => rtrim(getenv('KEYCLOAK_BASE_URL') ?: '', '/'), + 'realm' => getenv('KEYCLOAK_REALM') ?: '', + 'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: (rtrim(getenv('APP_URL') ?: 'https://totp.token2.swiss', '/') . '/auth/callback/keycloak'), + 'scope' => getenv('KEYCLOAK_SCOPE') ?: 'openid email profile', + ], + ], + 'session' => [ + 'lifetime' => 86400 * 30, + 'cookie_name' => 'authvault_session', + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml index 27e06db..da354f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,12 @@ services: - MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET:-} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - KEYCLOAK_BASE_URL=${KEYCLOAK_BASE_URL:-} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} + - KEYCLOAK_REDIRECT_URI=${KEYCLOAK_REDIRECT_URI:-} + - KEYCLOAK_SCOPE=${KEYCLOAK_SCOPE:-openid email profile} volumes: # Mount the Docker-specific config as config/config.php inside the container. # This keeps the original config/config.php on the host untouched. diff --git a/docker/init-db.sql b/docker/init-db.sql index 2df5d68..73e4006 100644 --- a/docker/init-db.sql +++ b/docker/init-db.sql @@ -74,3 +74,19 @@ CREATE TABLE IF NOT EXISTS `users` ( PRIMARY KEY (`id`), UNIQUE INDEX `idx_users_email` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ── oauth_identities ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS `oauth_identities` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `provider` varchar(64) NOT NULL, + `provider_id` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE INDEX `uniq_provider_identity` (`provider`, `provider_id`), + INDEX `idx_oauth_identities_user_id` (`user_id`), + CONSTRAINT `fk_oauth_identities_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/schema.sql b/schema.sql index b4a408a..af99596 100644 --- a/schema.sql +++ b/schema.sql @@ -1,25 +1,49 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- TOTPVault database schema +-- ───────────────────────────────────────────────────────────────────────────── --- --- Table structure for table `magic_links` --- - -CREATE TABLE `magic_links` ( - `id` int(11) NOT NULL, +CREATE TABLE IF NOT EXISTS `magic_links` ( + `id` int(11) NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `token_hash` varchar(64) NOT NULL, `used` tinyint(1) NOT NULL DEFAULT 0, `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' + `expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (`id`), + INDEX `idx_magic_links_email` (`email`), + INDEX `idx_magic_links_token_hash` (`token_hash`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `name` varchar(255) DEFAULT NULL, + `avatar_url` varchar(500) DEFAULT NULL, + `google_id` varchar(255) DEFAULT NULL, + `microsoft_id` varchar(255) DEFAULT NULL, + `github_id` varchar(255) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE INDEX `idx_users_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- --- Table structure for table `otp_profiles` --- +CREATE TABLE IF NOT EXISTS `oauth_identities` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `provider` varchar(64) NOT NULL, + `provider_id` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE INDEX `uniq_provider_identity` (`provider`, `provider_id`), + INDEX `idx_oauth_identities_user_id` (`user_id`), + CONSTRAINT `fk_oauth_identities_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE `otp_profiles` ( - `id` int(11) NOT NULL, +CREATE TABLE IF NOT EXISTS `otp_profiles` ( + `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `name` varchar(255) NOT NULL, `issuer` varchar(255) DEFAULT '', @@ -31,39 +55,20 @@ CREATE TABLE `otp_profiles` ( `icon` varchar(50) DEFAULT 'shield', `hide_code` tinyint(1) NOT NULL DEFAULT 0, `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + INDEX `idx_otp_profiles_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- -------------------------------------------------------- - --- --- Table structure for table `profile_shares` --- - -CREATE TABLE `profile_shares` ( - `id` int(11) NOT NULL, +CREATE TABLE IF NOT EXISTS `profile_shares` ( + `id` int(11) NOT NULL AUTO_INCREMENT, `profile_id` int(11) NOT NULL, `shared_by_user_id` int(11) NOT NULL, `shared_with_email` varchar(255) NOT NULL, `shared_with_user_id` int(11) DEFAULT NULL, `can_edit` tinyint(1) DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- -------------------------------------------------------- - --- --- Table structure for table `users` --- - -CREATE TABLE `users` ( - `id` int(11) NOT NULL, - `email` varchar(255) NOT NULL, - `name` varchar(255) DEFAULT NULL, - `avatar_url` varchar(500) DEFAULT NULL, - `google_id` varchar(255) DEFAULT NULL, - `microsoft_id` varchar(255) DEFAULT NULL, - `github_id` varchar(255) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + PRIMARY KEY (`id`), + INDEX `idx_profile_shares_profile_id` (`profile_id`), + INDEX `idx_profile_shares_shared_with_email` (`shared_with_email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/Auth.php b/src/Auth.php index 625b19c..8ab31de 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -1,114 +1,226 @@ -db = Database::getInstance(); - $this->cfg = require __DIR__ . '/../config/config.php'; - } - - // ── Session management ───────────────────────────────────────────────── - - public function startSession(): void { - if (session_status() === PHP_SESSION_NONE) { - $cookieName = $this->cfg['session']['cookie_name']; - session_name($cookieName); - session_set_cookie_params([ - 'lifetime' => $this->cfg['session']['lifetime'], - 'path' => '/', - 'secure' => isset($_SERVER['HTTPS']), - 'httponly' => true, - 'samesite' => 'Lax', - ]); - session_start(); - } - } - - public function currentUser(): ?array { - $this->startSession(); - if (empty($_SESSION['user_id'])) return null; - $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?'); - $stmt->execute([$_SESSION['user_id']]); - return $stmt->fetch() ?: null; - } - - public function requireLogin(): array { - $user = $this->currentUser(); - if (!$user) { - header('Location: /'); - exit; - } - return $user; - } - - public function loginUser(int $userId): void { - $this->startSession(); - session_regenerate_id(true); - $_SESSION['user_id'] = $userId; - } - - public function logout(): void { - $this->startSession(); - session_destroy(); - setcookie($this->cfg['session']['cookie_name'], '', time() - 3600, '/'); - } - - // ── User upsert ──────────────────────────────────────────────────────── - - public function findOrCreateUser(array $data): array { - // $data: email, name, avatar_url, provider (google|microsoft|github), provider_id - $providerCol = $data['provider'] . '_id'; - - // 1. Try by provider_id - $stmt = $this->db->prepare("SELECT * FROM users WHERE {$providerCol} = ?"); - $stmt->execute([$data['provider_id']]); - $user = $stmt->fetch(); - - // 2. Try by email (links accounts with same email) - if (!$user) { - $stmt = $this->db->prepare('SELECT * FROM users WHERE email = ?'); - $stmt->execute([$data['email']]); - $user = $stmt->fetch(); - } - - if ($user) { - // Update provider id + refresh name/avatar - $stmt = $this->db->prepare( - "UPDATE users SET {$providerCol} = ?, name = COALESCE(?, name), - avatar_url = COALESCE(?, avatar_url), updated_at = NOW() - WHERE id = ?" - ); - $stmt->execute([$data['provider_id'], $data['name'], $data['avatar_url'], $user['id']]); - $user[$providerCol] = $data['provider_id']; - return $user; - } - - // Create new user - $stmt = $this->db->prepare( - "INSERT INTO users (email, name, avatar_url, {$providerCol}) VALUES (?, ?, ?, ?)" - ); - $stmt->execute([$data['email'], $data['name'], $data['avatar_url'], $data['provider_id']]); - $newId = (int)$this->db->lastInsertId(); - - // Resolve pending shares - $this->resolvePendingShares($data['email'], $newId); - - return $this->findById($newId); - } - - public function findById(int $id): ?array { - $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?'); - $stmt->execute([$id]); - return $stmt->fetch() ?: null; - } - - private function resolvePendingShares(string $email, int $userId): void { - $stmt = $this->db->prepare( - 'UPDATE profile_shares SET shared_with_user_id = ? WHERE shared_with_email = ? AND shared_with_user_id IS NULL' - ); - $stmt->execute([$userId, $email]); - } -} +db = Database::getInstance(); + $this->cfg = require __DIR__ . '/../config/config.php'; + } + + // ── Session management ───────────────────────────────────────────────── + + public function startSession(): void { + if (session_status() === PHP_SESSION_NONE) { + $cookieName = $this->cfg['session']['cookie_name']; + session_name($cookieName); + session_set_cookie_params([ + 'lifetime' => $this->cfg['session']['lifetime'], + 'path' => '/', + 'secure' => isset($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + } + } + + public function currentUser(): ?array { + $this->startSession(); + if (empty($_SESSION['user_id'])) return null; + $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$_SESSION['user_id']]); + return $stmt->fetch() ?: null; + } + + public function requireLogin(): array { + $user = $this->currentUser(); + if (!$user) { + header('Location: /'); + exit; + } + return $user; + } + + public function loginUser(int $userId): void { + $this->startSession(); + session_regenerate_id(true); + $_SESSION['user_id'] = $userId; + } + + public function logout(): void { + $this->startSession(); + session_destroy(); + setcookie($this->cfg['session']['cookie_name'], '', time() - 3600, '/'); + } + + // ── User upsert ──────────────────────────────────────────────────────── + + public function findOrCreateUser(array $data): array { + // $data: email, name, avatar_url, provider, provider_id + $provider = $this->normalizeProviderName($data['provider'] ?? ''); + $providerId = (string)($data['provider_id'] ?? ''); + $email = strtolower(trim((string)($data['email'] ?? ''))); + + if ($providerId === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('OAuth provider returned incomplete user information.'); + } + + $this->db->beginTransaction(); + try { + $user = $this->findUserByOAuthIdentity($provider, $providerId) + ?? $this->findUserByLegacyProviderColumn($provider, $providerId) + ?? $this->findByEmail($email); + + if ($user) { + $stmt = $this->db->prepare( + 'UPDATE users SET name = COALESCE(?, name), + avatar_url = COALESCE(?, avatar_url), updated_at = NOW() + WHERE id = ?' + ); + $stmt->execute([$data['name'] ?? null, $data['avatar_url'] ?? null, $user['id']]); + $userId = (int)$user['id']; + } else { + $stmt = $this->db->prepare( + 'INSERT INTO users (email, name, avatar_url) VALUES (?, ?, ?)' + ); + $stmt->execute([$email, $data['name'] ?? null, $data['avatar_url'] ?? null]); + $userId = (int)$this->db->lastInsertId(); + $this->resolvePendingShares($email, $userId); + } + + $this->upsertOAuthIdentity($userId, $provider, $providerId); + $this->updateLegacyProviderColumn($userId, $provider, $providerId); + + $this->db->commit(); + $savedUser = $this->findById($userId); + if (!$savedUser) { + throw new RuntimeException('Failed to load authenticated user.'); + } + return $savedUser; + } catch (Throwable $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + } + + public function findById(int $id): ?array { + $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$id]); + return $stmt->fetch() ?: null; + } + + private function findByEmail(string $email): ?array { + $stmt = $this->db->prepare('SELECT * FROM users WHERE email = ?'); + $stmt->execute([$email]); + return $stmt->fetch() ?: null; + } + + private function findUserByOAuthIdentity(string $provider, string $providerId): ?array { + if (!$this->tableExists('oauth_identities')) { + return null; + } + + $stmt = $this->db->prepare( + 'SELECT u.* FROM users u + INNER JOIN oauth_identities oi ON oi.user_id = u.id + WHERE oi.provider = ? AND oi.provider_id = ? + LIMIT 1' + ); + $stmt->execute([$provider, $providerId]); + return $stmt->fetch() ?: null; + } + + private function findUserByLegacyProviderColumn(string $provider, string $providerId): ?array { + $column = $this->legacyProviderColumn($provider); + if ($column === null) { + return null; + } + + $stmt = $this->db->prepare("SELECT * FROM users WHERE {$column} = ? LIMIT 1"); + $stmt->execute([$providerId]); + return $stmt->fetch() ?: null; + } + + private function upsertOAuthIdentity(int $userId, string $provider, string $providerId): void { + if (!$this->tableExists('oauth_identities')) { + return; + } + + $stmt = $this->db->prepare( + 'INSERT INTO oauth_identities (user_id, provider, provider_id) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), updated_at = NOW()' + ); + $stmt->execute([$userId, $provider, $providerId]); + } + + private function updateLegacyProviderColumn(int $userId, string $provider, string $providerId): void { + $column = $this->legacyProviderColumn($provider); + if ($column === null) { + return; + } + + $stmt = $this->db->prepare("UPDATE users SET {$column} = ? WHERE id = ?"); + $stmt->execute([$providerId, $userId]); + } + + private function normalizeProviderName(string $provider): string { + $provider = strtolower(trim($provider)); + if (!preg_match('/^[a-z0-9_-]{1,64}$/', $provider)) { + throw new RuntimeException('Invalid OAuth provider name.'); + } + return $provider; + } + + private function legacyProviderColumn(string $provider): ?string { + $legacyColumns = [ + 'google' => 'google_id', + 'microsoft' => 'microsoft_id', + 'github' => 'github_id', + ]; + $column = $legacyColumns[$provider] ?? null; + if ($column === null || !$this->columnExists('users', $column)) { + return null; + } + return $column; + } + + private function tableExists(string $table): bool { + static $cache = []; + if (!array_key_exists($table, $cache)) { + $stmt = $this->db->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + ); + $stmt->execute([$table]); + $cache[$table] = (int)$stmt->fetchColumn() > 0; + } + return $cache[$table]; + } + + private function columnExists(string $table, string $column): bool { + static $cache = []; + $key = "{$table}.{$column}"; + if (!array_key_exists($key, $cache)) { + $stmt = $this->db->prepare( + 'SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?' + ); + $stmt->execute([$table, $column]); + $cache[$key] = (int)$stmt->fetchColumn() > 0; + } + return $cache[$key]; + } + + private function resolvePendingShares(string $email, int $userId): void { + $stmt = $this->db->prepare( + 'UPDATE profile_shares SET shared_with_user_id = ? WHERE shared_with_email = ? AND shared_with_user_id IS NULL' + ); + $stmt->execute([$userId, $email]); + } +} diff --git a/src/OAuthP.php b/src/OAuthP.php index 9e5dd0a..9588262 100644 --- a/src/OAuthP.php +++ b/src/OAuthP.php @@ -1,160 +1,222 @@ -providerName = $providerName; - $this->provider = $cfg['oauth'][$providerName]; -} - - public function getAuthUrl(): string { - $state = bin2hex(random_bytes(16)); - $_SESSION['oauth_state'] = $state; - $_SESSION['oauth_provider'] = $this->providerName; - - $params = [ - 'client_id' => $this->provider['client_id'], - 'redirect_uri' => $this->provider['redirect_uri'], - 'response_type' => 'code', - 'scope' => $this->provider['scope'], - 'state' => $state, - ]; - - if ($this->providerName === 'google') { - $params['access_type'] = 'online'; - } - - return $this->provider['auth_url'] . '?' . http_build_query($params); - } - - public function handleCallback(string $code, string $state): array { - if ($state !== ($_SESSION['oauth_state'] ?? '')) { - throw new RuntimeException('OAuth state mismatch — possible CSRF attack.'); - } - unset($_SESSION['oauth_state']); - - $token = $this->exchangeCode($code); - $userInfo = $this->fetchUserInfo($token['access_token']); - return $this->normalizeUser($userInfo); - } - - private function exchangeCode(string $code): array { - $body = [ - 'client_id' => $this->provider['client_id'], - 'client_secret' => $this->provider['client_secret'], - 'code' => $code, - 'redirect_uri' => $this->provider['redirect_uri'], - 'grant_type' => 'authorization_code', - ]; - - $headers = ['Content-Type: application/x-www-form-urlencoded']; - if ($this->providerName === 'github') { - $headers[] = 'Accept: application/json'; - } - - $response = $this->httpPost($this->provider['token_url'], http_build_query($body), $headers); - $data = json_decode($response, true); - if (empty($data['access_token'])) { - throw new RuntimeException('Failed to obtain access token: ' . $response); - } - return $data; - } - -private function fetchUserInfo(string $accessToken): array { - $headers = [ - "Authorization: Bearer {$accessToken}", - 'Accept: application/json', - 'User-Agent: OTPVault/1.0', - ]; - $response = $this->httpGet($this->provider['userinfo_url'], $headers); - $data = json_decode($response, true); - if (!is_array($data)) { - throw new RuntimeException('Failed to fetch user info: ' . $response); - } - - // GitHub: always fetch emails separately since profile email is often null - if ($this->providerName === 'github') { - $emailsJson = $this->httpGet('https://api.github.com/user/emails', $headers); - $emails = json_decode($emailsJson, true) ?? []; - - - // First try: primary + verified - foreach ($emails as $e) { - if (!empty($e['primary']) && !empty($e['verified'])) { - $data['email'] = $e['email']; - break; - } - } - // Second try: any verified - if (empty($data['email'])) { - foreach ($emails as $e) { - if (!empty($e['verified'])) { - $data['email'] = $e['email']; - break; - } - } - } - // Last resort: any email - if (empty($data['email']) && !empty($emails[0]['email'])) { - $data['email'] = $emails[0]['email']; - } - } - - return $data; -} - - private function normalizeUser(array $raw): array { - return match($this->providerName) { - 'google' => [ - 'provider' => 'google', - 'provider_id' => $raw['sub'], - 'email' => $raw['email'], - 'name' => $raw['name'] ?? null, - 'avatar_url' => $raw['picture'] ?? null, - ], - 'microsoft' => [ - 'provider' => 'microsoft', - 'provider_id' => $raw['id'], - 'email' => $raw['mail'] ?? $raw['userPrincipalName'] ?? null, - 'name' => $raw['displayName'] ?? null, - 'avatar_url' => null, - ], - 'github' => [ - 'provider' => 'github', - 'provider_id' => (string)$raw['id'], - 'email' => $raw['email'] ?? null, - 'name' => $raw['name'] ?? $raw['login'] ?? null, - 'avatar_url' => $raw['avatar_url'] ?? null, - ], - default => throw new RuntimeException('Unknown provider'), - }; - } - - private function httpPost(string $url, string $body, array $headers): string { - $ctx = stream_context_create(['http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", $headers), - 'content' => $body, - 'timeout' => 10, - 'ignore_errors' => true, - ]]); - return file_get_contents($url, false, $ctx) ?: throw new RuntimeException("HTTP POST to {$url} failed."); - } - - private function httpGet(string $url, array $headers): string { - $ctx = stream_context_create(['http' => [ - 'method' => 'GET', - 'header' => implode("\r\n", $headers), - 'timeout' => 10, - 'ignore_errors' => true, - ]]); - return file_get_contents($url, false, $ctx) ?: throw new RuntimeException("HTTP GET to {$url} failed."); - } -} +providerName = $providerName; + $this->provider = $cfg['oauth'][$providerName]; + $this->configureDiscoveredProvider(); +} + + public function getAuthUrl(): string { + foreach (['auth_url', 'client_id', 'redirect_uri', 'scope'] as $requiredKey) { + if (empty($this->provider[$requiredKey])) { + throw new RuntimeException("OAuth provider is missing required setting: {$requiredKey}"); + } + } + + $state = bin2hex(random_bytes(16)); + $_SESSION['oauth_state'] = $state; + $_SESSION['oauth_provider'] = $this->providerName; + + $params = [ + 'client_id' => $this->provider['client_id'], + 'redirect_uri' => $this->provider['redirect_uri'], + 'response_type' => 'code', + 'scope' => $this->provider['scope'], + 'state' => $state, + ]; + + if ($this->providerName === 'google') { + $params['access_type'] = 'online'; + } + + return $this->provider['auth_url'] . '?' . http_build_query($params); + } + + public function handleCallback(string $code, string $state): array { + if ($state !== ($_SESSION['oauth_state'] ?? '')) { + throw new RuntimeException('OAuth state mismatch — possible CSRF attack.'); + } + unset($_SESSION['oauth_state']); + + $token = $this->exchangeCode($code); + $userInfo = $this->fetchUserInfo($token['access_token']); + return $this->normalizeUser($userInfo); + } + + private function configureDiscoveredProvider(): void { + if ($this->providerName !== 'keycloak') { + return; + } + + $baseUrl = rtrim($this->provider['base_url'] ?? '', '/'); + $realm = trim($this->provider['realm'] ?? ''); + if ($baseUrl === '' || $realm === '') { + return; + } + + $discoveryUrl = "{$baseUrl}/realms/" . rawurlencode($realm) . '/.well-known/openid-configuration'; + $response = $this->httpGet($discoveryUrl, ['Accept: application/json']); + $discovery = json_decode($response, true); + if (!is_array($discovery)) { + throw new RuntimeException('Failed to read Keycloak OpenID Connect discovery document.'); + } + + foreach ([ + 'auth_url' => 'authorization_endpoint', + 'token_url' => 'token_endpoint', + 'userinfo_url' => 'userinfo_endpoint', + ] as $configKey => $discoveryKey) { + if (empty($this->provider[$configKey]) && !empty($discovery[$discoveryKey])) { + $this->provider[$configKey] = $discovery[$discoveryKey]; + } + } + } + + private function exchangeCode(string $code): array { + foreach (['token_url', 'client_id', 'redirect_uri'] as $requiredKey) { + if (empty($this->provider[$requiredKey])) { + throw new RuntimeException("OAuth provider is missing required setting: {$requiredKey}"); + } + } + + $body = [ + 'client_id' => $this->provider['client_id'], + 'client_secret' => $this->provider['client_secret'], + 'code' => $code, + 'redirect_uri' => $this->provider['redirect_uri'], + 'grant_type' => 'authorization_code', + ]; + + $headers = ['Content-Type: application/x-www-form-urlencoded']; + if ($this->providerName === 'github') { + $headers[] = 'Accept: application/json'; + } + + $response = $this->httpPost($this->provider['token_url'], http_build_query($body), $headers); + $data = json_decode($response, true); + if (empty($data['access_token'])) { + throw new RuntimeException('Failed to obtain access token: ' . $response); + } + return $data; + } + +private function fetchUserInfo(string $accessToken): array { + if (empty($this->provider['userinfo_url'])) { + throw new RuntimeException("OAuth provider is missing required setting: userinfo_url"); + } + + $headers = [ + "Authorization: Bearer {$accessToken}", + 'Accept: application/json', + 'User-Agent: OTPVault/1.0', + ]; + $response = $this->httpGet($this->provider['userinfo_url'], $headers); + $data = json_decode($response, true); + if (!is_array($data)) { + throw new RuntimeException('Failed to fetch user info: ' . $response); + } + + // GitHub: always fetch emails separately since profile email is often null + if ($this->providerName === 'github') { + $emailsJson = $this->httpGet('https://api.github.com/user/emails', $headers); + $emails = json_decode($emailsJson, true) ?? []; + + + // First try: primary + verified + foreach ($emails as $e) { + if (!empty($e['primary']) && !empty($e['verified'])) { + $data['email'] = $e['email']; + break; + } + } + // Second try: any verified + if (empty($data['email'])) { + foreach ($emails as $e) { + if (!empty($e['verified'])) { + $data['email'] = $e['email']; + break; + } + } + } + // Last resort: any email + if (empty($data['email']) && !empty($emails[0]['email'])) { + $data['email'] = $emails[0]['email']; + } + } + + return $data; +} + + private function normalizeUser(array $raw): array { + $user = match($this->providerName) { + 'google' => [ + 'provider' => 'google', + 'provider_id' => $raw['sub'], + 'email' => $raw['email'], + 'name' => $raw['name'] ?? null, + 'avatar_url' => $raw['picture'] ?? null, + ], + 'microsoft' => [ + 'provider' => 'microsoft', + 'provider_id' => $raw['id'], + 'email' => $raw['mail'] ?? $raw['userPrincipalName'] ?? null, + 'name' => $raw['displayName'] ?? null, + 'avatar_url' => null, + ], + 'github' => [ + 'provider' => 'github', + 'provider_id' => (string)$raw['id'], + 'email' => $raw['email'] ?? null, + 'name' => $raw['name'] ?? $raw['login'] ?? null, + 'avatar_url' => $raw['avatar_url'] ?? null, + ], + 'keycloak' => [ + 'provider' => 'keycloak', + 'provider_id' => $raw['sub'] ?? null, + 'email' => $raw['email'] ?? null, + 'name' => $raw['name'] ?? $raw['preferred_username'] ?? null, + 'avatar_url' => $raw['picture'] ?? null, + ], + default => throw new RuntimeException('Unknown provider'), + }; + + if (empty($user['provider_id'])) { + throw new RuntimeException("OAuth provider did not return a stable user identifier."); + } + if (empty($user['email'])) { + throw new RuntimeException("OAuth provider did not return an email address."); + } + + return $user; + } + + private function httpPost(string $url, string $body, array $headers): string { + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'timeout' => 10, + 'ignore_errors' => true, + ]]); + return file_get_contents($url, false, $ctx) ?: throw new RuntimeException("HTTP POST to {$url} failed."); + } + + private function httpGet(string $url, array $headers): string { + $ctx = stream_context_create(['http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $headers), + 'timeout' => 10, + 'ignore_errors' => true, + ]]); + return file_get_contents($url, false, $ctx) ?: throw new RuntimeException("HTTP GET to {$url} failed."); + } +} diff --git a/templates/landing.php b/templates/landing.php index 3c204b0..0c871ad 100644 --- a/templates/landing.php +++ b/templates/landing.php @@ -1,142 +1,145 @@ - - - - - - - -<?= htmlspecialchars($pageTitle) ?> - - - - - - - - -
- - - - - - -

- - - -
- ✅ Check your inbox — we sent a sign-in link to . It will expire in 15 minutes. -

- - - -
-
- - - - Server-side secret protection -
-

TOTP codes without exposing secrets

-

- TOTPVault generates your one-time passwords on the server — your secret keys never reach the browser. - Supports SHA1, SHA256, & SHA512. Share OTP codes with teammates without exposing secrets. -

- -
- - -
-
- -
Everything you need for secure 2FA
-

- Manage all your TOTP tokens in one place, with enterprise-grade security built from the ground up. -

-
-
-
-
🔐
-
Server-side generation
-
- OTP codes are generated on the server. Secret keys are AES-256-GCM encrypted at rest and never transmitted to the client. -
-
-
-
⚙️
-
All TOTP standards
-
- Full support for SHA1, SHA256, and SHA512. Configure 6, 8, or 10-digit codes. Custom periods supported. -
-
-
-
👥
-
Secure sharing
-
- Share OTP profiles with colleagues via email. They receive codes — never the secrets. Works with Google, Microsoft, and GitHub, or just an email address using magic links. -
-
-
-
🌐
-
Social login
-
- Sign in with Google, Microsoft, or GitHub. Multiple providers link automatically when using the same email. -
-
-
-
-
Live countdown
-
- Real-time timer shows exactly when codes refresh. Auto-refresh before expiry. Copy codes with one click. -
-
-
-
🎨
-
Organised & labelled
-
- Color-code your profiles, add issuers, choose icons. Keep dozens of tokens organised at a glance. -
-
-
-
- - -
-
- -
Sign in to TOTPVault
-

Enter your email to receive a sign-in link, or continue with an existing account.

- + +/* Footer */ +.footer { + padding: 2rem 1.5rem; + text-align: center; + color: var(--ink4); + font-size: .875rem; + border-top: 1px solid var(--line); +} + +/* Section headings */ +.section-label { + font-size: .8125rem; + font-weight: 600; + color: var(--blue); + letter-spacing: .08em; + text-transform: uppercase; + margin-bottom: .75rem; +} +.section-title { + font-size: clamp(1.75rem, 3vw, 2.5rem); + font-weight: 700; + letter-spacing: -.02em; + color: var(--ink); +} +.section-sub { + font-size: 1rem; + color: var(--ink3); + margin-top: .75rem; + max-width: 520px; + margin-left: auto; + margin-right: auto; + line-height: 1.7; +} + +/* Error banner */ +.error-banner { + max-width: 480px; + margin: 1rem auto 0; + padding: .875rem 1.25rem; + background: var(--red-l); + color: var(--red); + border-radius: var(--r2); + font-size: .875rem; + display: flex; + align-items: center; + gap: .5rem; +} + + + + + + + + +
+ + + + + + +

+ + + +
+ ✅ Check your inbox — we sent a sign-in link to . It will expire in 15 minutes. +

+ + + +
+
+ + + + Server-side secret protection +
+

TOTP codes without exposing secrets

+

+ TOTPVault generates your one-time passwords on the server — your secret keys never reach the browser. + Supports SHA1, SHA256, & SHA512. Share OTP codes with teammates without exposing secrets. +

+ +
+ + +
+
+ +
Everything you need for secure 2FA
+

+ Manage all your TOTP tokens in one place, with enterprise-grade security built from the ground up. +

+
+
+
+
🔐
+
Server-side generation
+
+ OTP codes are generated on the server. Secret keys are AES-256-GCM encrypted at rest and never transmitted to the client. +
+
+
+
⚙️
+
All TOTP standards
+
+ Full support for SHA1, SHA256, and SHA512. Configure 6, 8, or 10-digit codes. Custom periods supported. +
+
+
+
👥
+
Secure sharing
+
+ Share OTP profiles with colleagues via email. They receive codes — never the secrets. Works with Google, Microsoft, and GitHub, or just an email address using magic links. +
+
+
+
🌐
+
Social login
+
+ Sign in with Google, Microsoft, or GitHub. Multiple providers link automatically when using the same email. +
+
+
+
+
Live countdown
+
+ Real-time timer shows exactly when codes refresh. Auto-refresh before expiry. Copy codes with one click. +
+
+
+
🎨
+
Organised & labelled
+
+ Color-code your profiles, add issuers, choose icons. Keep dozens of tokens organised at a glance. +
+
+
+
+ + +
+
+ +
Sign in to TOTPVault
+

Enter your email to receive a sign-in link, or continue with an existing account.

+
- - - + + + Continue with Google @@ -350,9 +353,9 @@ require __DIR__ . '/layout.php'; - - - + + + Continue with Microsoft @@ -375,13 +378,29 @@ require __DIR__ . '/layout.php'; + + + + + + + + + + + Continue with Keycloak + + + + +
- -
-

© Token2 — Made in Switzerland — Secrets never leave the server — Open Source

-
- - + + + +