Add Keycloak OIDC provider support

This commit is contained in:
Tamir Suliman
2026-05-17 11:52:23 +02:00
parent 797a000e59
commit 1aeaf6aee5
10 changed files with 958 additions and 757 deletions
+11
View File
@@ -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
+28 -75
View File
@@ -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:
+9
View File
@@ -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',
],
],
];
+63 -55
View File
@@ -1,55 +1,63 @@
<?php
// config/config.php
return [
'db' => [
'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',
],
];
<?php
// config/config.php
return [
'db' => [
'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',
],
];
+6
View File
@@ -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.
+16
View File
@@ -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;
+47 -42
View File
@@ -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;
+226 -114
View File
@@ -1,114 +1,226 @@
<?php
// src/Auth.php
class Auth {
private PDO $db;
private array $cfg;
public function __construct() {
$this->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]);
}
}
<?php
// src/Auth.php
class Auth {
private PDO $db;
private array $cfg;
public function __construct() {
$this->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]);
}
}
+222 -160
View File
@@ -1,160 +1,222 @@
<?php
// src/OAuthP.php
class OAuthP {
private array $provider;
private string $providerName;
public function __construct(string $providerName) {
$cfg = require __DIR__ . '/../config/config.php';
if (!isset($cfg['oauth'][$providerName])) {
throw new InvalidArgumentException("Unknown OAuth provider: {$providerName}");
}
$this->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.");
}
}
<?php
// src/OAuthP.php
class OAuthP {
private array $provider;
private string $providerName;
public function __construct(string $providerName) {
$cfg = require __DIR__ . '/../config/config.php';
if (!isset($cfg['oauth'][$providerName])) {
throw new InvalidArgumentException("Unknown OAuth provider: {$providerName}");
}
$this->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.");
}
}
+330 -311
View File
File diff suppressed because it is too large Load Diff