Files
kazeyukiro c79e0b7a17 feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
feat(auth): optional GitHub OAuth for panel login
Store client id/secret and allowed GitHub usernames in settings.
OAuth start/callback issues the same session JWT as password login;
first allowed login can bind to matching or sole admin account.
Login page shows Continue with GitHub when enabled.
2026-09-26 01:05:10 +00:00

292 lines
8.0 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"gorm.io/gorm"
)
const githubOAuthSettingKey = "github_oauth"
// GithubOAuthSettings is stored as JSON in panel_settings.
type GithubOAuthSettings struct {
Enabled bool `json:"enabled"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AllowedLogins []string `json:"allowed_logins"` // GitHub usernames (case-insensitive); empty = only already-linked accounts
}
type githubOAuthPublic struct {
Enabled bool `json:"enabled"`
ClientID string `json:"client_id,omitempty"`
}
type oauthStateEntry struct {
Created time.Time
}
var (
oauthStates = map[string]oauthStateEntry{}
oauthStatesMu sync.Mutex
)
func loadGithubOAuthSettings(db *gorm.DB) (GithubOAuthSettings, error) {
var row models.PanelSetting
var s GithubOAuthSettings
if err := db.Where("key = ?", githubOAuthSettingKey).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return s, nil
}
return s, err
}
if row.Value == "" {
return s, nil
}
_ = json.Unmarshal([]byte(row.Value), &s)
return s, nil
}
func saveGithubOAuthSettings(db *gorm.DB, s GithubOAuthSettings) error {
// Normalize allowed logins
out := make([]string, 0, len(s.AllowedLogins))
seen := map[string]struct{}{}
for _, l := range s.AllowedLogins {
l = strings.ToLower(strings.TrimSpace(l))
if l == "" {
continue
}
if _, ok := seen[l]; ok {
continue
}
seen[l] = struct{}{}
out = append(out, l)
}
s.AllowedLogins = out
raw, err := json.Marshal(s)
if err != nil {
return err
}
var row models.PanelSetting
err = db.Where("key = ?", githubOAuthSettingKey).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return db.Create(&models.PanelSetting{Key: githubOAuthSettingKey, Value: string(raw)}).Error
}
if err != nil {
return err
}
return db.Model(&row).Update("value", string(raw)).Error
}
func publicGithubOAuth(s GithubOAuthSettings) githubOAuthPublic {
if !s.Enabled || strings.TrimSpace(s.ClientID) == "" {
return githubOAuthPublic{Enabled: false}
}
return githubOAuthPublic{Enabled: true, ClientID: strings.TrimSpace(s.ClientID)}
}
func newOAuthState() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
state := base64.RawURLEncoding.EncodeToString(b)
oauthStatesMu.Lock()
// prune old
now := time.Now()
for k, v := range oauthStates {
if now.Sub(v.Created) > 15*time.Minute {
delete(oauthStates, k)
}
}
oauthStates[state] = oauthStateEntry{Created: now}
oauthStatesMu.Unlock()
return state, nil
}
func consumeOAuthState(state string) bool {
oauthStatesMu.Lock()
defer oauthStatesMu.Unlock()
e, ok := oauthStates[state]
if !ok {
return false
}
delete(oauthStates, state)
return time.Since(e.Created) <= 15*time.Minute
}
func resolveOAuthRedirectBase(cHost, publicURL string) string {
pub := strings.TrimSpace(publicURL)
if pub != "" {
return strings.TrimRight(pub, "/")
}
// Fallback: derive from request (may be wrong behind broken reverse proxies).
return strings.TrimRight(cHost, "/")
}
func githubAuthorizeURL(clientID, redirectURI, state string) string {
q := url.Values{}
q.Set("client_id", clientID)
q.Set("redirect_uri", redirectURI)
q.Set("scope", "read:user")
q.Set("state", state)
return "https://github.com/login/oauth/authorize?" + q.Encode()
}
type githubTokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
type githubUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
}
func exchangeGithubCode(ctx context.Context, clientID, clientSecret, code, redirectURI string) (string, error) {
form := url.Values{}
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var tr githubTokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return "", fmt.Errorf("parse token response: %w", err)
}
if tr.Error != "" {
return "", fmt.Errorf("github token: %s (%s)", tr.Error, tr.ErrorDesc)
}
if tr.AccessToken == "" {
return "", errors.New("github returned empty access_token")
}
return tr.AccessToken, nil
}
func fetchGithubUser(ctx context.Context, accessToken string) (*githubUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "3m-ui-oauth")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("github user api: %s", strings.TrimSpace(string(body)))
}
var u githubUser
if err := json.Unmarshal(body, &u); err != nil {
return nil, err
}
if u.ID == 0 || u.Login == "" {
return nil, errors.New("github user missing id/login")
}
return &u, nil
}
func loginOrLinkGithubUser(db *gorm.DB, jwtSecret string, gh *githubUser, allowed []string) (*LoginResult, error) {
ghID := fmt.Sprintf("%d", gh.ID)
loginLower := strings.ToLower(strings.TrimSpace(gh.Login))
var user models.User
err := db.Where("github_id = ?", ghID).First(&user).Error
if err == nil {
return issueSessionForUser(db, jwtSecret, &user)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Not linked yet: require allowlist (or match username).
allowedOK := false
for _, a := range allowed {
if strings.ToLower(strings.TrimSpace(a)) == loginLower {
allowedOK = true
break
}
}
if !allowedOK {
return nil, errors.New("github account not allowed; add the username to allowed_logins in Settings, or link an account first")
}
// Prefer existing user with same username as GitHub login.
err = db.Where("LOWER(username) = ?", loginLower).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Fall back to the sole administrator.
var admins []models.User
if qerr := db.Where("role = ?", "admin").Limit(2).Find(&admins).Error; qerr != nil {
return nil, qerr
}
if len(admins) != 1 {
return nil, errors.New("no local user to bind; create an admin with the same username as GitHub, or keep a single admin account")
}
user = admins[0]
} else if err != nil {
return nil, err
}
// Bind GitHub identity.
if err := db.Model(&user).Updates(map[string]interface{}{
"github_id": ghID,
"github_login": gh.Login,
}).Error; err != nil {
return nil, err
}
user.GithubID = ghID
user.GithubLogin = gh.Login
return issueSessionForUser(db, jwtSecret, &user)
}
func issueSessionForUser(db *gorm.DB, jwtSecret string, user *models.User) (*LoginResult, error) {
if user.SessionVersion == 0 {
user.SessionVersion = 1
if err := db.Model(user).Update("session_version", user.SessionVersion).Error; err != nil {
return nil, err
}
}
token, exp, err := GenerateToken(jwtSecret, user.ID, user.Username, user.Role, user.SessionVersion, DefaultTokenTTL)
if err != nil {
return nil, err
}
return &LoginResult{
Token: token,
ExpiresAt: exp,
Username: user.Username,
Role: user.Role,
MustChangePassword: user.MustChangePassword,
}, nil
}