docs: wire GitHub auth mode into UI

This commit is contained in:
devcxl 2026-04-04 07:34:59 +08:00
parent 97f6ada0e0
commit ef4f4a7cca
5 changed files with 362 additions and 7 deletions

View File

@ -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.

View File

@ -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)
}
}

View File

@ -148,6 +148,7 @@
</a>
</li>
{{if not .loginDisabled}}
{{if and (not (eq .authMethod "github")) .baseData.Admin}}
<li class="nav-item">
<a href="{{.basePath}}/users-settings" class="nav-link {{if eq .baseData.Active "users-settings" }}active{{end}}">
<i class="nav-icon fas fa-cog"></i>
@ -159,6 +160,7 @@
</li>
{{end}}
{{end}}
{{end}}
<li class="nav-header">工具</li>
<li class="nav-item">

View File

@ -31,6 +31,15 @@
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">登录以开始使用</p>
{{if eq .authMethod "github"}}
<div class="text-center">
<p class="mb-4">使用 GitHub 账户登录</p>
<a href="{{.basePath}}/auth/github/start" id="btn_github_login" class="btn btn-github btn-block btn-lg">
<i class="fab fa-github mr-2"></i> 使用 GitHub 登录
</a>
<p id="message" class="mt-3"></p>
</div>
{{else}}
<form action="" method="post">
<div class="input-group mb-3">
<input id="username" type="text" class="form-control" placeholder="用户名">
@ -67,6 +76,7 @@
<div class="text-center mb-3">
<p id="message"></p>
</div>
{{end}}
</div>
<!-- /.login-card-body -->
</div>
@ -87,10 +97,26 @@
if (nextURL && /(?:^\/[a-zA-Z_])|(?:^\/$)/.test(nextURL.trim())) {
window.location.href = nextURL;
} else {
window.location.href = '/{{.basePath}}';
}
window.location.href = '{{.basePath}}/';
}
}
</script>
{{if eq .authMethod "github"}}
<script>
$(document).ready(function () {
$("#btn_github_login").click(function (e) {
e.preventDefault();
const urlParams = new URLSearchParams(window.location.search);
const nextURL = urlParams.get('next');
let redirectUrl = '{{.basePath}}/auth/github/start';
if (nextURL) {
redirectUrl += '?next=' + encodeURIComponent(nextURL);
}
window.location.href = redirectUrl;
});
});
</script>
{{else}}
<script>
$(document).ready(function () {
$('form').on('submit', function(e) {
@ -127,4 +153,5 @@
});
});
</script>
{{end}}
</html>

View File

@ -20,17 +20,43 @@
<div class="row">
<!-- left column -->
<div class="col-md-6">
{{if eq .authMethod "github"}}
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">身份信息</h3>
</div>
<!-- /.card-header -->
<div class="card-body">
<div class="form-group">
<label class="control-label">用户名</label>
<input type="text" class="form-control" value="" id="username" readonly>
</div>
<div class="form-group">
<label class="control-label">显示名称</label>
<input type="text" class="form-control" value="" id="display_name" readonly>
</div>
<div class="form-group">
<label class="control-label">认证来源</label>
<input type="text" class="form-control" value="GitHub" id="auth_source" readonly>
</div>
<div class="form-group">
<label class="control-label">角色</label>
<input type="text" class="form-control" value="" id="role" readonly>
</div>
</div>
</div>
{{else}}
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">更新用户信息</h3>
} </div>
</div>
<!-- /.card-header -->
<!-- form start -->
<form role="form" id="frm_profile" name="frm_profile">
<div class="card-body">
<div class="form-group">
<label for="username" class="control-label">用户名</label>
} <input type="text" class="form-control" name="username" id="username"
<input type="text" class="form-control" name="username" id="username"
value="">
</div>
<div class="form-group">
@ -46,6 +72,7 @@
</form>
</div>
<!-- /.card -->
{{end}}
</div>
</div>
<!-- /.row -->
@ -71,6 +98,10 @@
$("#username").val(user.username);
previous_username = user.username;
admin = user.admin;
{{if eq .authMethod "github"}}
$("#display_name").val(user.display_name || user.username);
$("#role").val(user.admin ? '管理员' : '普通用户');
{{end}}
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
@ -78,8 +109,10 @@
}
});
});
</script>
{{if not (eq .authMethod "github")}}
<script>
function updateUserInfo() {
const username = $("#username").val();
const password = $("#password").val();
@ -93,7 +126,7 @@
data: JSON.stringify(data),
success: function (data) {
toastr.success("已成功更新用户信息");
} location.reload();
location.reload();
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
@ -117,7 +150,7 @@
messages: {
username: {
required: "请输入用户名",
} }
}
},
errorElement: 'span',
errorPlacement: function (error, element) {
@ -133,4 +166,5 @@
});
});
</script>
{{end}}
{{ end }}