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.
292 lines
8.0 KiB
Go
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
|
|
}
|