mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-09 01:47:07 +08:00
Legacy GitHub bindings stored the login name, while current bindings store the numeric account ID. A legacy match no longer signs the user in on its own. When the account has two-factor or a passkey, the binding is rewritten only after that verification completes, inside the transaction that issues the session. Otherwise it is rewritten when one of the GitHub account's verified emails matches the account email. Without either, the login is declined with guidance to sign in another way and relink GitHub in account settings. All-digit login names never take part in the comparison, the bind flow no longer compares login names, and every migration or declined attempt is recorded as an account binding audit event.
228 lines
7.3 KiB
Go
228 lines
7.3 KiB
Go
package oauth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func init() {
|
|
Register("github", &GitHubProvider{})
|
|
}
|
|
|
|
// GitHubProvider implements OAuth for GitHub
|
|
type GitHubProvider struct{}
|
|
|
|
type gitHubOAuthResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
Scope string `json:"scope"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
|
|
type gitHubUser struct {
|
|
Id int64 `json:"id"` // GitHub numeric ID (permanent, never changes)
|
|
Login string `json:"login"` // GitHub username (can be changed by user)
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type gitHubEmail struct {
|
|
Email string `json:"email"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
func (p *GitHubProvider) GetName() string {
|
|
return "GitHub"
|
|
}
|
|
|
|
func (p *GitHubProvider) IsEnabled() bool {
|
|
return common.GitHubOAuthEnabled
|
|
}
|
|
|
|
func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
|
|
if code == "" {
|
|
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
|
|
}
|
|
|
|
values := map[string]string{
|
|
"client_id": common.GitHubClientId,
|
|
"client_secret": common.GitHubClientSecret,
|
|
"code": code,
|
|
}
|
|
jsonData, err := json.Marshal(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
client := http.Client{
|
|
Timeout: 20 * time.Second,
|
|
}
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] ExchangeToken error: %s", err.Error()))
|
|
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "GitHub"}, err.Error())
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken response status: %d", res.StatusCode)
|
|
|
|
var oAuthResponse gitHubOAuthResponse
|
|
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
|
|
if err != nil {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] ExchangeToken decode error: %s", err.Error()))
|
|
return nil, err
|
|
}
|
|
|
|
if oAuthResponse.AccessToken == "" {
|
|
logger.LogError(ctx, "[OAuth-GitHub] ExchangeToken failed: empty access token")
|
|
return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "GitHub"})
|
|
}
|
|
|
|
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken success: scope=%s", oAuthResponse.Scope)
|
|
|
|
return &OAuthToken{
|
|
AccessToken: oAuthResponse.AccessToken,
|
|
TokenType: oAuthResponse.TokenType,
|
|
Scope: oAuthResponse.Scope,
|
|
}, nil
|
|
}
|
|
|
|
func (p *GitHubProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
|
|
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo: fetching user info")
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
|
|
|
client := http.Client{
|
|
Timeout: 20 * time.Second,
|
|
}
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo error: %s", err.Error()))
|
|
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "GitHub"}, err.Error())
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo response status: %d", res.StatusCode)
|
|
|
|
// Check for non-200 status codes before attempting to decode
|
|
if res.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(res.Body)
|
|
bodyStr := string(body)
|
|
if len(bodyStr) > 500 {
|
|
bodyStr = bodyStr[:500] + "..."
|
|
}
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s", res.StatusCode, bodyStr))
|
|
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, map[string]any{"Provider": "GitHub"}, fmt.Sprintf("status %d", res.StatusCode))
|
|
}
|
|
|
|
var githubUser gitHubUser
|
|
err = json.NewDecoder(res.Body).Decode(&githubUser)
|
|
if err != nil {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo decode error: %s", err.Error()))
|
|
return nil, err
|
|
}
|
|
|
|
if githubUser.Id == 0 || githubUser.Login == "" {
|
|
logger.LogError(ctx, "[OAuth-GitHub] GetUserInfo failed: empty id or login field")
|
|
return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": "GitHub"})
|
|
}
|
|
|
|
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo success: id=%d, login=%s, name=%s, email=%s",
|
|
githubUser.Id, githubUser.Login, githubUser.Name, githubUser.Email)
|
|
|
|
return &OAuthUser{
|
|
ProviderUserID: strconv.FormatInt(githubUser.Id, 10), // Use numeric ID as primary identifier
|
|
Username: githubUser.Login,
|
|
DisplayName: githubUser.Name,
|
|
Email: githubUser.Email,
|
|
Extra: map[string]any{
|
|
"legacy_id": githubUser.Login, // Store login for migration from old accounts
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// GetVerifiedEmails lists the addresses GitHub has confirmed for the signed-in
|
|
// account. The email field of the user endpoint carries no confirmation status
|
|
// and is not used for this.
|
|
func (p *GitHubProvider) GetVerifiedEmails(ctx context.Context, token *OAuthToken) ([]string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/emails", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
|
|
client := http.Client{
|
|
Timeout: 20 * time.Second,
|
|
}
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetVerifiedEmails error: %s", err.Error()))
|
|
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "GitHub"}, err.Error())
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetVerifiedEmails failed: status=%d", res.StatusCode))
|
|
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, map[string]any{"Provider": "GitHub"}, fmt.Sprintf("status %d", res.StatusCode))
|
|
}
|
|
|
|
var emails []gitHubEmail
|
|
if err := common.DecodeJson(res.Body, &emails); err != nil {
|
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetVerifiedEmails decode error: %s", err.Error()))
|
|
return nil, err
|
|
}
|
|
verified := make([]string, 0, len(emails))
|
|
for _, email := range emails {
|
|
if email.Verified && email.Email != "" {
|
|
verified = append(verified, email.Email)
|
|
}
|
|
}
|
|
logger.LogDebug(ctx, "[OAuth-GitHub] GetVerifiedEmails success: verified=%d", len(verified))
|
|
return verified, nil
|
|
}
|
|
|
|
func (p *GitHubProvider) IsUserIDTaken(providerUserID string) bool {
|
|
return model.IsGitHubIdAlreadyTaken(providerUserID)
|
|
}
|
|
|
|
func (p *GitHubProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
|
user.GitHubId = providerUserID
|
|
return user.FillUserByGitHubId()
|
|
}
|
|
|
|
func (p *GitHubProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
|
user.GitHubId = providerUserID
|
|
}
|
|
|
|
func (p *GitHubProvider) GetProviderPrefix() string {
|
|
return "github_"
|
|
}
|
|
|
|
// ProviderUserIDColumn returns the users-table column storing this provider's user ID.
|
|
func (p *GitHubProvider) ProviderUserIDColumn() string {
|
|
return "github_id"
|
|
}
|