mirror of
https://github.com/token2/TOTPVault.git
synced 2026-06-22 23:42:25 -07:00
Add Keycloak OIDC provider support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user