mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 23:06:36 +08:00
354 lines
10 KiB
Go
354 lines
10 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/db"
|
|
"github.com/Rain-kl/Wavelet/internal/listener"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
errGitHubOAuthDisabled = "管理员未开启通过 GitHub 登录以及注册"
|
|
errWeChatOAuthDisabled = "管理员未开启通过微信登录以及注册"
|
|
errRegistrationClosed = "管理员关闭了新用户注册"
|
|
errGitHubAlreadyBound = "该 GitHub 账户已被绑定"
|
|
errWeChatAlreadyBound = "该微信账号已被绑定"
|
|
)
|
|
|
|
type githubOAuthResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
|
|
type githubUser struct {
|
|
Login string `json:"login"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type wechatLoginResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
// GitHubOAuth handles the legacy GET /oauth/github shortcut.
|
|
func GitHubOAuth(ctx context.Context, c *gin.Context, code string) (LegacyUser, error) {
|
|
if current := currentUserFromLegacyToken(ctx, c); current != nil {
|
|
if err := GitHubBind(ctx, c, current, code); err != nil {
|
|
return LegacyUser{}, err
|
|
}
|
|
return LegacyUser{}, nil
|
|
}
|
|
|
|
if !model.GitHubOAuthEnabled {
|
|
return LegacyUser{}, errors.New(errGitHubOAuthDisabled)
|
|
}
|
|
|
|
githubUser, err := getGitHubUserInfoByCode(code)
|
|
if err != nil {
|
|
return LegacyUser{}, err
|
|
}
|
|
|
|
user, err := findUserByShortcutBinding(ctx, githubUser.Login, "github", "GitHub")
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return LegacyUser{}, errors.New(errRegistrationClosed)
|
|
}
|
|
return LegacyUser{}, err
|
|
}
|
|
if !user.IsActive {
|
|
return LegacyUser{}, errors.New(errBannedAccount)
|
|
}
|
|
return finishLegacyLogin(ctx, c, user)
|
|
}
|
|
|
|
// GitHubBind binds a GitHub account to the current user.
|
|
func GitHubBind(ctx context.Context, c *gin.Context, current *model.User, code string) error {
|
|
if current == nil {
|
|
return errors.New(errUnauthorized)
|
|
}
|
|
if !model.GitHubOAuthEnabled {
|
|
return errors.New(errGitHubOAuthDisabled)
|
|
}
|
|
|
|
githubUser, err := getGitHubUserInfoByCode(code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := findUserByShortcutBinding(ctx, githubUser.Login, "github", "GitHub"); err == nil {
|
|
return errors.New(errGitHubAlreadyBound)
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
|
|
return bindShortcutExternalAccount(ctx, current.ID, githubUser.Login, githubUser.Login, githubUser.Email, "github", "GitHub")
|
|
}
|
|
|
|
// WeChatOAuth handles the legacy GET /oauth/wechat shortcut.
|
|
func WeChatOAuth(ctx context.Context, c *gin.Context, code string) (LegacyUser, error) {
|
|
if !model.WeChatAuthEnabled {
|
|
return LegacyUser{}, errors.New(errWeChatOAuthDisabled)
|
|
}
|
|
|
|
wechatID, err := getWeChatIDByCode(code)
|
|
if err != nil {
|
|
return LegacyUser{}, err
|
|
}
|
|
|
|
user, err := findUserByShortcutBinding(ctx, wechatID, "wechat", "WeChat")
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return LegacyUser{}, errors.New(errRegistrationClosed)
|
|
}
|
|
return LegacyUser{}, err
|
|
}
|
|
if !user.IsActive {
|
|
return LegacyUser{}, errors.New(errBannedAccount)
|
|
}
|
|
return finishLegacyLogin(ctx, c, user)
|
|
}
|
|
|
|
// WeChatBind binds a WeChat account to the current user.
|
|
func WeChatBind(ctx context.Context, userID uint64, code string) error {
|
|
if userID == 0 {
|
|
return errors.New(errUnauthorized)
|
|
}
|
|
if !model.WeChatAuthEnabled {
|
|
return errors.New(errWeChatOAuthDisabled)
|
|
}
|
|
|
|
wechatID, err := getWeChatIDByCode(code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := findUserByShortcutBinding(ctx, wechatID, "wechat", "WeChat"); err == nil {
|
|
return errors.New(errWeChatAlreadyBound)
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
|
|
return bindShortcutExternalAccount(ctx, userID, wechatID, wechatID, "", "wechat", "WeChat")
|
|
}
|
|
|
|
// EmailBind binds a verified email address to the current user.
|
|
func EmailBind(ctx context.Context, userID uint64, email, code string) error {
|
|
email = strings.TrimSpace(email)
|
|
code = strings.TrimSpace(code)
|
|
if userID == 0 {
|
|
return errors.New(errUnauthorized)
|
|
}
|
|
if email == "" || code == "" {
|
|
return errors.New(errInvalidParams)
|
|
}
|
|
if !verifyEmailCode(ctx, email, "register", code) {
|
|
return errors.New(errEmailCodeInvalid)
|
|
}
|
|
|
|
var user model.User
|
|
if err := db.DB(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
|
return errors.New(errUserNotFound)
|
|
}
|
|
user.Email = email
|
|
return db.DB(ctx).Model(&user).Update("email", email).Error
|
|
}
|
|
|
|
func finishLegacyLogin(ctx context.Context, c *gin.Context, user *model.User) (LegacyUser, error) {
|
|
if user == nil {
|
|
return LegacyUser{}, errors.New(errUserNotFound)
|
|
}
|
|
user.LastLoginAt = time.Now()
|
|
if err := db.DB(ctx).Model(user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
|
|
return LegacyUser{}, err
|
|
}
|
|
if err := setLoginSession(ctx, c, user); err != nil {
|
|
return LegacyUser{}, errors.New(errSaveSessionFailed)
|
|
}
|
|
token, err := issueLegacyAccessToken(ctx, user)
|
|
if err != nil {
|
|
return LegacyUser{}, err
|
|
}
|
|
logger.InfoF(ctx, "[LoginAudit] successful legacy shortcut login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
|
|
listener.EmitAdminLoggedIn(ctx, user, c.ClientIP())
|
|
return ToLegacyUser(user, token), nil
|
|
}
|
|
|
|
func getGitHubUserInfoByCode(code string) (*githubUser, error) {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return nil, errors.New(errInvalidParams)
|
|
}
|
|
values := map[string]string{
|
|
"client_id": model.GitHubClientId,
|
|
"client_secret": model.GitHubClientSecret,
|
|
"code": code,
|
|
}
|
|
jsonData, err := json.Marshal(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := http.Client{Timeout: 5 * time.Second}
|
|
req, err := http.NewRequest(http.MethodPost, "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")
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
slog.Error("github oauth access token request failed", "error", err)
|
|
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
var oauthResponse githubOAuthResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&oauthResponse); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(oauthResponse.AccessToken) == "" {
|
|
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
|
}
|
|
|
|
req, err = http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oauthResponse.AccessToken))
|
|
|
|
res2, err := client.Do(req)
|
|
if err != nil {
|
|
slog.Error("github user info request failed", "error", err)
|
|
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
|
}
|
|
defer res2.Body.Close()
|
|
|
|
var ghUser githubUser
|
|
if err := json.NewDecoder(res2.Body).Decode(&ghUser); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(ghUser.Login) == "" {
|
|
return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
|
|
}
|
|
return &ghUser, nil
|
|
}
|
|
|
|
func getWeChatIDByCode(code string) (string, error) {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return "", errors.New(errInvalidParams)
|
|
}
|
|
serverAddress := strings.TrimRight(strings.TrimSpace(model.WeChatServerAddress), "/")
|
|
if serverAddress == "" {
|
|
return "", errors.New(errWeChatOAuthDisabled)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/wechat/user?code=%s", serverAddress, code), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", model.WeChatServerToken)
|
|
|
|
client := http.Client{Timeout: 5 * time.Second}
|
|
httpResponse, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func(body io.ReadCloser) {
|
|
if closeErr := body.Close(); closeErr != nil {
|
|
slog.Error("failed to close wechat response body", "error", closeErr)
|
|
}
|
|
}(httpResponse.Body)
|
|
|
|
var res wechatLoginResponse
|
|
if err := json.NewDecoder(httpResponse.Body).Decode(&res); err != nil {
|
|
return "", err
|
|
}
|
|
if !res.Success {
|
|
if strings.TrimSpace(res.Message) == "" {
|
|
return "", errors.New(errInvalidParams)
|
|
}
|
|
return "", errors.New(res.Message)
|
|
}
|
|
if strings.TrimSpace(res.Data) == "" {
|
|
return "", errors.New(errEmailCodeInvalid)
|
|
}
|
|
return strings.TrimSpace(res.Data), nil
|
|
}
|
|
|
|
func findUserByShortcutBinding(ctx context.Context, externalID string, sourceNames ...string) (*model.User, error) {
|
|
externalID = strings.TrimSpace(externalID)
|
|
if externalID == "" {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
query := db.DB(ctx).
|
|
Table("w_external_accounts AS ea").
|
|
Select("u.*").
|
|
Joins("JOIN w_users u ON u.id = ea.user_id").
|
|
Where("ea.external_id = ?", externalID)
|
|
if len(sourceNames) > 0 {
|
|
lowered := make([]string, 0, len(sourceNames))
|
|
for _, name := range sourceNames {
|
|
trimmed := strings.ToLower(strings.TrimSpace(name))
|
|
if trimmed != "" {
|
|
lowered = append(lowered, trimmed)
|
|
}
|
|
}
|
|
if len(lowered) > 0 {
|
|
query = query.
|
|
Joins("JOIN w_auth_sources s ON s.id = ea.auth_source_id").
|
|
Where("LOWER(s.name) IN ?", lowered)
|
|
}
|
|
}
|
|
|
|
var user model.User
|
|
if err := query.First(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func bindShortcutExternalAccount(ctx context.Context, userID uint64, externalID, externalUsername, email string, sourceNames ...string) error {
|
|
source, err := resolveShortcutAuthSource(ctx, sourceNames...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return model.BindExternalAccount(ctx, &model.ExternalAccount{
|
|
AuthSourceID: source.ID,
|
|
UserID: userID,
|
|
ExternalID: strings.TrimSpace(externalID),
|
|
ExternalUsername: strings.TrimSpace(externalUsername),
|
|
Email: strings.TrimSpace(email),
|
|
})
|
|
}
|
|
|
|
func resolveShortcutAuthSource(ctx context.Context, sourceNames ...string) (*model.AuthSource, error) {
|
|
for _, name := range sourceNames {
|
|
source, err := model.GetAuthSourceByName(ctx, name)
|
|
if err == nil {
|
|
return source, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, errors.New("认证源不存在")
|
|
}
|