diff --git a/README.md b/README.md index 5f40ba7..9d9cb7e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,40 @@ docker-compose up | `SMTP_AUTH_TYPE` | The SMTP authentication type. Possible values: `PLAIN`, `LOGIN`, `NONE` | `NONE` | | `SMTP_ENCRYPTION` | The encryption method. Possible values: `NONE`, `SSL`, `SSLTLS`, `TLS`, `STARTTLS` | `STARTTLS` | | `SMTP_HELO` | Hostname to use for the HELO message. smtp-relay.gmail.com needs this set to anything but `localhost` | `localhost` | + +### GitHub OAuth + +Set `WGUI_AUTH_METHOD=github` to enable GitHub OAuth authentication. When enabled, the local password-based login form is replaced with a GitHub OAuth flow. + +| Variable | Description | Default | +|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------| +| `WGUI_AUTH_METHOD` | Authentication method. Possible values: `local`, `github` | `local` | +| `WGUI_GITHUB_CLIENT_ID` | GitHub OAuth application client ID | N/A (required for github mode) | +| `WGUI_GITHUB_CLIENT_SECRET` | GitHub OAuth application client secret | N/A (required for github mode) | +| `WGUI_GITHUB_CLIENT_SECRET_FILE` | Optional filepath for the GitHub OAuth client secret. Leave `WGUI_GITHUB_CLIENT_SECRET` blank to take effect | N/A | +| `WGUI_GITHUB_REDIRECT_URL` | GitHub OAuth callback URL (must match the callback URL configured in your GitHub App) | N/A (required for github mode) | +| `WGUI_GITHUB_ALLOWED_USERS` | Comma-separated list of GitHub usernames allowed to login | N/A (required for github mode) | +| `WGUI_GITHUB_ALLOWED_ORGS` | Comma-separated list of GitHub organizations whose members are allowed to login | N/A | +| `WGUI_GITHUB_ADMIN_USERS` | Comma-separated list of GitHub usernames that should be granted admin privileges | N/A (required for github mode) | + +#### GitHub App Setup + +1. Create a GitHub App at [GitHub Developer Settings](https://github.com/settings/developers) + - Set the callback URL to: `https://your-domain.com/auth/github/callback` (or `/your-base-path/auth/github/callback` if using a subpath) + - Enable "Request user authorization (OAuth) during installation" + - Set the following permissions: + - Account: Read-only (for user email and username) +2. Note the Client ID and Client Secret from the app +3. Configure the environment variables + +#### Mode Switching + +- Switching from `local` to `github` mode: Existing sessions remain valid until they expire. Users will see the GitHub login button on next login. +- Switching from `github` to `local` mode: Existing sessions remain valid until they expire. Users who log in again will use the password form. +- First startup after upgrading to a build that includes GitHub OAuth support: Existing sessions from the previous version are invalidated once and users must re-authenticate. + +> **Note:** After upgrading wireguard-ui with GitHub OAuth support, sessions from the previous version will be invalidated. Users will need to re-authenticate. + ### Defaults for server configuration These environment variables are used to control the default server settings used when initializing the database. diff --git a/handler/routes_auth_mode_test.go b/handler/routes_auth_mode_test.go new file mode 100644 index 0000000..12c8558 --- /dev/null +++ b/handler/routes_auth_mode_test.go @@ -0,0 +1,258 @@ +package handler + +import ( + "os" + "strings" + "testing" + + "github.com/ngoduykhanh/wireguard-ui/util" +) + +func TestLoginPage_GitHubModeShowsOAuthButton(t *testing.T) { + content, err := os.ReadFile("../templates/login.html") + if err != nil { + t.Fatalf("failed to read login.html: %v", err) + } + body := string(content) + + if !strings.Contains(body, "auth/github/start") { + t.Errorf("expected login page to contain 'auth/github/start' in github mode") + } + + if !strings.Contains(body, ".authMethod") { + t.Errorf("expected login page to use .authMethod for mode switching") + } + + if !strings.Contains(body, "btn-github") && !strings.Contains(body, "github-login") { + t.Errorf("expected login page to have GitHub login button") + } +} + +func TestLoginPage_LocalModeKeepsPasswordForm(t *testing.T) { + content, err := os.ReadFile("../templates/login.html") + if err != nil { + t.Fatalf("failed to read login.html: %v", err) + } + body := string(content) + + if !strings.Contains(body, `id="username"`) { + t.Errorf("expected login page to contain username input in local mode") + } + + if !strings.Contains(body, `id="password"`) { + t.Errorf("expected login page to contain password input in local mode") + } +} + +func TestLoginPage_GitHubModeWithNextParam(t *testing.T) { + content, err := os.ReadFile("../templates/login.html") + if err != nil { + t.Fatalf("failed to read login.html: %v", err) + } + body := string(content) + + if !strings.Contains(body, "{{if .authMethod}}") && !strings.Contains(body, ".authMethod") { + t.Errorf("expected login page to use .authMethod to conditionally show OAuth button") + } + + if !strings.Contains(body, "auth/github/start") { + t.Errorf("expected login page to contain 'auth/github/start' for OAuth flow") + } +} + +func TestLoginPage_GitHubModeDoesNotShowPasswordForm(t *testing.T) { + content, err := os.ReadFile("../templates/login.html") + if err != nil { + t.Fatalf("failed to read login.html: %v", err) + } + body := string(content) + + lines := strings.Split(body, "\n") + inGitHubBlock := false + hasPasswordInGitHubBlock := false + + for _, line := range lines { + if strings.Contains(line, "{{if eq .authMethod \"github\"}}") { + inGitHubBlock = true + } + if inGitHubBlock && strings.Contains(line, "{{else}}") { + inGitHubBlock = false + } + if inGitHubBlock && strings.Contains(line, `id="password"`) { + hasPasswordInGitHubBlock = true + break + } + } + + if hasPasswordInGitHubBlock { + t.Errorf("expected password input NOT to be in github mode block") + } +} + +func TestGitHubModeUsersSettingsNotRegistered(t *testing.T) { + content, err := os.ReadFile("../main.go") + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + body := string(content) + + lines := strings.Split(body, "\n") + inGitHubBlock := false + hasUsersSettingsInGitHubBlock := false + + for _, line := range lines { + if strings.Contains(line, "AuthMethodGitHub") { + inGitHubBlock = true + } + if inGitHubBlock && strings.Contains(line, "} else {") { + inGitHubBlock = false + break + } + if inGitHubBlock && strings.Contains(line, "users-settings") { + hasUsersSettingsInGitHubBlock = true + break + } + } + + if hasUsersSettingsInGitHubBlock { + t.Errorf("users-settings route should not be registered in github mode block") + } +} + +func TestGitHubStartHandlerRegistered(t *testing.T) { + content, err := os.ReadFile("../main.go") + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + body := string(content) + + if !strings.Contains(body, "auth/github/start") { + t.Errorf("main.go should register auth/github/start route for github mode") + } + + if !strings.Contains(body, "GitHubStart") { + t.Errorf("main.go should use GitHubStart handler") + } +} + +func TestGitHubCallbackHandlerRegistered(t *testing.T) { + content, err := os.ReadFile("../main.go") + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + body := string(content) + + if !strings.Contains(body, "auth/github/callback") { + t.Errorf("main.go should register auth/github/callback route for github mode") + } + + if !strings.Contains(body, "GitHubCallback") { + t.Errorf("main.go should use GitHubCallback handler") + } +} + +func TestGitHubModeProfileAPIRegistered(t *testing.T) { + content, err := os.ReadFile("../main.go") + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + body := string(content) + + lines := strings.Split(body, "\n") + inGitHubBlock := false + hasProfileAPIInGitHubBlock := false + + for _, line := range lines { + if strings.Contains(line, "AuthMethodGitHub") { + inGitHubBlock = true + } + if inGitHubBlock && strings.Contains(line, "} else {") { + inGitHubBlock = false + break + } + if inGitHubBlock && strings.Contains(line, `"/api/user/:username"`) { + hasProfileAPIInGitHubBlock = true + break + } + } + + if !hasProfileAPIInGitHubBlock { + t.Errorf("github mode should register /api/user/:username for profile page") + } +} + +func TestProfilePage_GitHubModeReadOnlyInfo(t *testing.T) { + content, err := os.ReadFile("../templates/profile.html") + if err != nil { + t.Fatalf("failed to read profile.html: %v", err) + } + body := string(content) + + if !strings.Contains(body, ".authMethod") { + t.Errorf("expected profile page to use .authMethod for mode switching") + } + + if !strings.Contains(body, "github") { + t.Errorf("expected profile page to mention github in github mode") + } +} + +func TestProfilePage_LocalModeHasPasswordForm(t *testing.T) { + content, err := os.ReadFile("../templates/profile.html") + if err != nil { + t.Fatalf("failed to read profile.html: %v", err) + } + body := string(content) + + if !strings.Contains(body, `id="password"`) { + t.Errorf("expected profile page to contain password input in local mode") + } +} + +func TestBaseTemplate_GitHubModeHidesUserSettings(t *testing.T) { + content, err := os.ReadFile("../templates/base.html") + if err != nil { + t.Fatalf("failed to read base.html: %v", err) + } + body := string(content) + + if !strings.Contains(body, ".authMethod") { + t.Errorf("expected base.html to use .authMethod for mode switching") + } + + if !strings.Contains(body, "users-settings") { + t.Errorf("expected base.html to contain 'users-settings' link") + } + + lines := strings.Split(body, "\n") + inAdminBlock := false + hasUserSettingsWithoutGithubCheck := false + + for _, line := range lines { + if strings.Contains(line, "{{if .baseData.Admin}}") { + inAdminBlock = true + } + if inAdminBlock && strings.Contains(line, "{{end}}") && !strings.Contains(line, "{{else}}") { + inAdminBlock = false + } + if inAdminBlock && strings.Contains(line, "users-settings") && strings.Contains(line, "{{if") && strings.Contains(line, "github") { + hasUserSettingsWithoutGithubCheck = true + break + } + } + + if !hasUserSettingsWithoutGithubCheck { + return + } + t.Errorf("users-settings should be hidden in github mode") +} + +func TestUtil_CurrentAuthMethodType(t *testing.T) { + if string(util.AuthMethodGitHub) != "github" { + t.Errorf("AuthMethodGitHub should be 'github', got '%s'", util.AuthMethodGitHub) + } + + if string(util.AuthMethodLocal) != "local" { + t.Errorf("AuthMethodLocal should be 'local', got '%s'", util.AuthMethodLocal) + } +} diff --git a/templates/base.html b/templates/base.html index 2cb595d..d13a1f0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -148,6 +148,7 @@ {{if not .loginDisabled}} + {{if and (not (eq .authMethod "github")) .baseData.Admin}}
登录以开始使用
+ {{if eq .authMethod "github"}} +使用 GitHub 账户登录
+ + 使用 GitHub 登录 + + +