mirror of
https://github.com/Eugeny/tabby-web.git
synced 2026-08-16 22:36:02 +01:00
Merge pull request #142 from brian-olson/feature/dynamic-auth-providers
This commit is contained in:
@@ -35,7 +35,7 @@ RUN pip install -U setuptools cryptography==37.0.4 poetry==1.1.7
|
|||||||
COPY backend/pyproject.toml backend/poetry.lock ./
|
COPY backend/pyproject.toml backend/poetry.lock ./
|
||||||
RUN poetry config virtualenvs.path /venv
|
RUN poetry config virtualenvs.path /venv
|
||||||
RUN poetry install --no-dev --no-ansi --no-interaction
|
RUN poetry install --no-dev --no-ansi --no-interaction
|
||||||
RUN poetry run pip install -U setuptools psycopg2-binary $EXTRA_DEPS
|
RUN poetry run pip install -U setuptools psycopg2-binary python-jose[cryptography] $EXTRA_DEPS
|
||||||
|
|
||||||
COPY backend/manage.py backend/gunicorn.conf.py ./
|
COPY backend/manage.py backend/gunicorn.conf.py ./
|
||||||
COPY backend/tabby tabby
|
COPY backend/tabby tabby
|
||||||
|
|||||||
58
README.md
58
README.md
@@ -57,6 +57,21 @@ Tabby Web serves the [Tabby Terminal](https://github.com/Eugeny/tabby) as a web
|
|||||||
| RAM | 2GB |
|
| RAM | 2GB |
|
||||||
| Disk | 10GB |
|
| Disk | 10GB |
|
||||||
|
|
||||||
|
## Docker Build Requirements
|
||||||
|
|
||||||
|
Building the Docker image requires significant resources due to the frontend compilation:
|
||||||
|
|
||||||
|
| Resource | Minimum | Recommended |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| RAM | 2 GB | 4 GB |
|
||||||
|
| CPU | 2 cores | 4 cores |
|
||||||
|
| Disk | 5 GB | 10 GB |
|
||||||
|
|
||||||
|
**Note:** The frontend build (webpack/Angular) is memory-intensive. If building on constrained systems (like Oracle Cloud Always Free tier with 1GB RAM), consider:
|
||||||
|
- Using pre-built images from a CI/CD pipeline
|
||||||
|
- Building on a larger machine and pushing to a registry
|
||||||
|
- Adding swap space (not recommended for production)
|
||||||
|
|
||||||
# Quickstart (using `docker-compose`)
|
# Quickstart (using `docker-compose`)
|
||||||
|
|
||||||
You'll need:
|
You'll need:
|
||||||
@@ -94,19 +109,52 @@ For SSH and Telnet, once logged in, enter your connection gateway address and au
|
|||||||
* `DATABASE_URL` (required).
|
* `DATABASE_URL` (required).
|
||||||
* `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in.
|
* `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in.
|
||||||
|
|
||||||
### OAuth Providers
|
### Authentication Providers
|
||||||
|
|
||||||
Configure one or more OAuth providers for authentication:
|
Only providers with credentials configured will appear as login options. Set the following environment variables for each provider you want to enable:
|
||||||
|
|
||||||
| Provider | Variables |
|
| Provider | Environment Variables |
|
||||||
|----------|-----------|
|
|----------|----------------------|
|
||||||
| GitHub | `SOCIAL_AUTH_GITHUB_KEY`, `SOCIAL_AUTH_GITHUB_SECRET` |
|
| GitHub | `SOCIAL_AUTH_GITHUB_KEY`, `SOCIAL_AUTH_GITHUB_SECRET` |
|
||||||
| GitLab | `SOCIAL_AUTH_GITLAB_KEY`, `SOCIAL_AUTH_GITLAB_SECRET` |
|
| GitLab | `SOCIAL_AUTH_GITLAB_KEY`, `SOCIAL_AUTH_GITLAB_SECRET` |
|
||||||
| Google | `SOCIAL_AUTH_GOOGLE_OAUTH2_KEY`, `SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET` |
|
| Google | `SOCIAL_AUTH_GOOGLE_OAUTH2_KEY`, `SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET` |
|
||||||
| Microsoft (multi-tenant) | `SOCIAL_AUTH_MICROSOFT_GRAPH_KEY`, `SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET` |
|
| Microsoft (multi-tenant) | `SOCIAL_AUTH_MICROSOFT_GRAPH_KEY`, `SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET` |
|
||||||
| Azure AD (single-tenant) | `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY`, `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET`, `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID` |
|
| Azure AD (single-tenant) | `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY`, `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET`, `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID` |
|
||||||
|
| Auth0 | `SOCIAL_AUTH_AUTH0_DOMAIN`, `SOCIAL_AUTH_AUTH0_KEY`, `SOCIAL_AUTH_AUTH0_SECRET` |
|
||||||
|
| Generic OIDC | `SOCIAL_AUTH_OIDC_OIDC_ENDPOINT`, `SOCIAL_AUTH_OIDC_KEY`, `SOCIAL_AUTH_OIDC_SECRET` |
|
||||||
|
|
||||||
**Azure AD Single-Tenant:** Use this instead of Microsoft Graph if you want to restrict login to users from a specific Azure AD tenant (organization). Set `TENANT_ID` to your Azure AD Directory (tenant) ID.
|
For Auth0, set the callback URL to: `https://your-domain/api/1/auth/social/complete/auth0/`
|
||||||
|
|
||||||
|
### Generic OIDC Provider
|
||||||
|
|
||||||
|
The generic OIDC provider works with any OpenID Connect compliant identity provider, including:
|
||||||
|
- **Authentik** - Self-hosted identity provider
|
||||||
|
- **Authelia** - Self-hosted authentication server
|
||||||
|
- **Keycloak** - Open source identity management
|
||||||
|
- **Okta** - Enterprise identity platform
|
||||||
|
- And any other OIDC-compliant provider
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
- `SOCIAL_AUTH_OIDC_OIDC_ENDPOINT`: The OIDC discovery endpoint (e.g., `https://authentik.example.com/application/o/<app-slug>/`)
|
||||||
|
- `SOCIAL_AUTH_OIDC_KEY`: Client ID from your identity provider
|
||||||
|
- `SOCIAL_AUTH_OIDC_SECRET`: Client secret from your identity provider
|
||||||
|
- `SOCIAL_AUTH_OIDC_NAME` (optional): Custom button text (default: "SSO")
|
||||||
|
|
||||||
|
Set the callback URL to: `https://your-domain/api/1/auth/social/complete/oidc/`
|
||||||
|
|
||||||
|
**Note on MFA:** Multi-factor authentication is handled by your identity provider. Enable MFA in Authentik, Authelia, or your chosen provider to require 2FA for Tabby Web logins.
|
||||||
|
|
||||||
|
### Azure AD Single-Tenant
|
||||||
|
|
||||||
|
For organizations that want to restrict login to a specific Azure AD/Entra ID tenant (instead of allowing any Microsoft account), use the Azure AD single-tenant provider:
|
||||||
|
|
||||||
|
- `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY`: Application (client) ID from Azure portal
|
||||||
|
- `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET`: Client secret
|
||||||
|
- `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID`: Directory (tenant) ID
|
||||||
|
|
||||||
|
Set the callback URL to: `https://your-domain/api/1/auth/social/complete/azuread-tenant-oauth2/`
|
||||||
|
|
||||||
|
When registering your app in Azure portal, select "Accounts in this organizational directory only" for supported account types.
|
||||||
|
|
||||||
## Adding Tabby app versions
|
## Adding Tabby app versions
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ router.register(
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/1/auth/logout", auth.LogoutView.as_view()),
|
path("api/1/auth/logout", auth.LogoutView.as_view()),
|
||||||
|
path("api/1/auth/providers", auth.ProvidersView.as_view()),
|
||||||
path("api/1/user", user.UserViewSet.as_view({"get": "retrieve", "put": "update"})),
|
path("api/1/user", user.UserViewSet.as_view({"get": "retrieve", "put": "update"})),
|
||||||
path(
|
path(
|
||||||
"api/1/gateways/choose",
|
"api/1/gateways/choose",
|
||||||
|
|||||||
@@ -1,9 +1,101 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
# Provider configuration: maps backend names to their display info
|
||||||
|
# and the environment variable prefix used to detect if they're configured
|
||||||
|
PROVIDER_CONFIG = {
|
||||||
|
'github': {
|
||||||
|
'name': 'GitHub',
|
||||||
|
'icon': 'github',
|
||||||
|
'cls': 'btn-primary',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_GITHUB',
|
||||||
|
},
|
||||||
|
'gitlab': {
|
||||||
|
'name': 'GitLab',
|
||||||
|
'icon': 'gitlab',
|
||||||
|
'cls': 'btn-warning',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_GITLAB',
|
||||||
|
},
|
||||||
|
'google-oauth2': {
|
||||||
|
'name': 'Google',
|
||||||
|
'icon': 'google',
|
||||||
|
'cls': 'btn-secondary',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_GOOGLE_OAUTH2',
|
||||||
|
},
|
||||||
|
'microsoft-graph': {
|
||||||
|
'name': 'Microsoft',
|
||||||
|
'icon': 'microsoft',
|
||||||
|
'cls': 'btn-light',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_MICROSOFT_GRAPH',
|
||||||
|
},
|
||||||
|
'azuread-tenant-oauth2': {
|
||||||
|
'name': 'Azure AD',
|
||||||
|
'icon': 'microsoft',
|
||||||
|
'cls': 'btn-light',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2',
|
||||||
|
},
|
||||||
|
'auth0': {
|
||||||
|
'name': 'Auth0',
|
||||||
|
'icon': 'key', # Using key icon as Auth0 doesn't have a FA brand icon
|
||||||
|
'cls': 'btn-dark',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_AUTH0',
|
||||||
|
},
|
||||||
|
'oidc': {
|
||||||
|
'name': 'SSO', # Generic name, can be overridden via SOCIAL_AUTH_OIDC_NAME
|
||||||
|
'icon': 'openid', # OpenID icon
|
||||||
|
'cls': 'btn-info',
|
||||||
|
'env_prefix': 'SOCIAL_AUTH_OIDC',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_provider_configured(env_prefix: str) -> bool:
|
||||||
|
"""Check if a provider has both KEY and SECRET configured."""
|
||||||
|
key = getattr(settings, f'{env_prefix}_KEY', None)
|
||||||
|
secret = getattr(settings, f'{env_prefix}_SECRET', None)
|
||||||
|
# For Auth0, also need DOMAIN
|
||||||
|
if env_prefix == 'SOCIAL_AUTH_AUTH0':
|
||||||
|
domain = getattr(settings, f'{env_prefix}_DOMAIN', None)
|
||||||
|
return bool(key and secret and domain)
|
||||||
|
# For generic OIDC, also need OIDC_ENDPOINT
|
||||||
|
if env_prefix == 'SOCIAL_AUTH_OIDC':
|
||||||
|
endpoint = getattr(settings, f'{env_prefix}_OIDC_ENDPOINT', None)
|
||||||
|
return bool(key and secret and endpoint)
|
||||||
|
# For Azure AD Tenant (single-tenant), also need TENANT_ID
|
||||||
|
if env_prefix == 'SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2':
|
||||||
|
tenant_id = getattr(settings, f'{env_prefix}_TENANT_ID', None)
|
||||||
|
return bool(key and secret and tenant_id)
|
||||||
|
return bool(key and secret)
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_display_name(env_prefix: str, default_name: str) -> str:
|
||||||
|
"""Get custom display name for a provider, if configured."""
|
||||||
|
custom_name = getattr(settings, f'{env_prefix}_NAME', None)
|
||||||
|
return custom_name if custom_name else default_name
|
||||||
|
|
||||||
|
|
||||||
class LogoutView(APIView):
|
class LogoutView(APIView):
|
||||||
def post(self, request, format=None):
|
def post(self, request, format=None):
|
||||||
logout(request)
|
logout(request)
|
||||||
return Response(None)
|
return Response(None)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersView(APIView):
|
||||||
|
"""Returns list of configured authentication providers."""
|
||||||
|
|
||||||
|
def get(self, request, format=None):
|
||||||
|
providers = []
|
||||||
|
for provider_id, config in PROVIDER_CONFIG.items():
|
||||||
|
if is_provider_configured(config['env_prefix']):
|
||||||
|
providers.append({
|
||||||
|
'id': provider_id,
|
||||||
|
'name': get_provider_display_name(
|
||||||
|
config['env_prefix'], config['name']
|
||||||
|
),
|
||||||
|
'icon': config['icon'],
|
||||||
|
'cls': config['cls'],
|
||||||
|
})
|
||||||
|
return Response(providers)
|
||||||
|
|||||||
@@ -138,13 +138,19 @@ AUTHENTICATION_BACKENDS = (
|
|||||||
"social_core.backends.github.GithubOAuth2",
|
"social_core.backends.github.GithubOAuth2",
|
||||||
"social_core.backends.gitlab.GitLabOAuth2",
|
"social_core.backends.gitlab.GitLabOAuth2",
|
||||||
"social_core.backends.azuread.AzureADOAuth2",
|
"social_core.backends.azuread.AzureADOAuth2",
|
||||||
"social_core.backends.azuread_tenant.AzureADTenantOAuth2", # Single-tenant Azure AD
|
"social_core.backends.azuread_tenant.AzureADTenantOAuth2",
|
||||||
"social_core.backends.microsoft.MicrosoftOAuth2",
|
"social_core.backends.microsoft.MicrosoftOAuth2",
|
||||||
"social_core.backends.google.GoogleOAuth2",
|
"social_core.backends.google.GoogleOAuth2",
|
||||||
|
"social_core.backends.auth0.Auth0OAuth2",
|
||||||
|
"social_core.backends.open_id_connect.OpenIdConnectAuth",
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
)
|
)
|
||||||
|
|
||||||
SOCIAL_AUTH_GITHUB_SCOPE = ["read:user", "user:email"]
|
SOCIAL_AUTH_GITHUB_SCOPE = ["read:user", "user:email"]
|
||||||
|
SOCIAL_AUTH_AUTH0_SCOPE = ["openid", "profile", "email"]
|
||||||
|
SOCIAL_AUTH_AUTH0_EXTRA_DATA = ["id_token"]
|
||||||
|
# Auth0 uses RS256 for ID tokens
|
||||||
|
SOCIAL_AUTH_ALLOWED_REDIRECT_HOSTS = ["*"]
|
||||||
SOCIAL_AUTH_PIPELINE = (
|
SOCIAL_AUTH_PIPELINE = (
|
||||||
"social_core.pipeline.social_auth.social_details",
|
"social_core.pipeline.social_auth.social_details",
|
||||||
"social_core.pipeline.social_auth.social_uid",
|
"social_core.pipeline.social_auth.social_uid",
|
||||||
@@ -181,7 +187,13 @@ for key in [
|
|||||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET",
|
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET",
|
||||||
"SOCIAL_AUTH_MICROSOFT_GRAPH_KEY",
|
"SOCIAL_AUTH_MICROSOFT_GRAPH_KEY",
|
||||||
"SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET",
|
"SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET",
|
||||||
# Azure AD single-tenant (use instead of multi-tenant for org-only access)
|
"SOCIAL_AUTH_AUTH0_DOMAIN",
|
||||||
|
"SOCIAL_AUTH_AUTH0_KEY",
|
||||||
|
"SOCIAL_AUTH_AUTH0_SECRET",
|
||||||
|
"SOCIAL_AUTH_OIDC_OIDC_ENDPOINT",
|
||||||
|
"SOCIAL_AUTH_OIDC_KEY",
|
||||||
|
"SOCIAL_AUTH_OIDC_SECRET",
|
||||||
|
"SOCIAL_AUTH_OIDC_NAME",
|
||||||
"SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY",
|
"SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY",
|
||||||
"SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET",
|
"SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET",
|
||||||
"SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID",
|
"SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
.login-view(*ngIf='ready')
|
.login-view(*ngIf='ready')
|
||||||
.buttons
|
.buttons(*ngIf='providers.length')
|
||||||
a.btn(
|
a.btn(
|
||||||
*ngFor='let provider of providers',
|
*ngFor='let provider of providers',
|
||||||
[class]='provider.cls',
|
[class]='provider.cls',
|
||||||
href='{{commonService.backendURL}}/api/1/auth/social/login/{{provider.id}}'
|
href='{{commonService.backendURL}}/api/1/auth/social/login/{{provider.id}}'
|
||||||
)
|
)
|
||||||
fa-icon([icon]='provider.icon', [fixedWidth]='true')
|
fa-icon([icon]='provider.faIcon', [fixedWidth]='true')
|
||||||
span Log in with {{provider.name}}
|
span Log in with {{provider.name}}
|
||||||
|
.no-providers(*ngIf='!providers.length')
|
||||||
|
p No authentication providers configured.
|
||||||
|
p.text-muted Contact your administrator.
|
||||||
|
|||||||
@@ -23,3 +23,12 @@
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-providers {
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { LoginService, CommonService } from 'src/common'
|
import { LoginService, CommonService } from 'src/common'
|
||||||
|
|
||||||
import { faGithub, faGitlab, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons'
|
import { faGithub, faGitlab, faGoogle, faMicrosoft, faOpenid, IconDefinition } from '@fortawesome/free-brands-svg-icons'
|
||||||
|
import { faKey } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
interface Provider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
cls: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map icon names from backend to FontAwesome icons
|
||||||
|
const iconMap: Record<string, IconDefinition> = {
|
||||||
|
github: faGithub,
|
||||||
|
gitlab: faGitlab,
|
||||||
|
google: faGoogle,
|
||||||
|
microsoft: faMicrosoft,
|
||||||
|
openid: faOpenid, // Used for generic OIDC providers (Authentik, Authelia, Keycloak, etc.)
|
||||||
|
key: faKey, // Used for Auth0 and other providers without brand icons
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'login',
|
selector: 'login',
|
||||||
@@ -11,20 +30,27 @@ import { faGithub, faGitlab, faGoogle, faMicrosoft } from '@fortawesome/free-bra
|
|||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
loggedIn: any
|
loggedIn: any
|
||||||
ready = false
|
ready = false
|
||||||
|
providers: Array<Provider & { faIcon: IconDefinition }> = []
|
||||||
providers = [
|
|
||||||
{ name: 'GitHub', icon: faGithub, cls: 'btn-primary', id: 'github' },
|
|
||||||
{ name: 'GitLab', icon: faGitlab, cls: 'btn-warning', id: 'gitlab' },
|
|
||||||
{ name: 'Google', icon: faGoogle, cls: 'btn-secondary', id: 'google-oauth2' },
|
|
||||||
{ name: 'Microsoft', icon: faMicrosoft, cls: 'btn-light', id: 'microsoft-graph' },
|
|
||||||
]
|
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
|
private http: HttpClient,
|
||||||
private loginService: LoginService,
|
private loginService: LoginService,
|
||||||
public commonService: CommonService,
|
public commonService: CommonService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async ngOnInit () {
|
async ngOnInit () {
|
||||||
|
// Fetch available providers from backend
|
||||||
|
try {
|
||||||
|
const providers = await this.http.get<Provider[]>('/api/1/auth/providers').toPromise()
|
||||||
|
this.providers = (providers || []).map(p => ({
|
||||||
|
...p,
|
||||||
|
faIcon: iconMap[p.icon] || faKey,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
// Fallback to empty list if API fails
|
||||||
|
this.providers = []
|
||||||
|
}
|
||||||
|
|
||||||
await this.loginService.ready$.toPromise()
|
await this.loginService.ready$.toPromise()
|
||||||
this.loggedIn = !!this.loginService.user
|
this.loggedIn = !!this.loginService.user
|
||||||
this.ready = true
|
this.ready = true
|
||||||
|
|||||||
Reference in New Issue
Block a user