mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
migrate
This commit is contained in:
@@ -5,6 +5,7 @@ package auth
|
||||
|
||||
const (
|
||||
errInvalidParams = "无效的参数"
|
||||
errUnauthorized = "无权进行此操作,未登录或 token 无效"
|
||||
errPasswordLoginDisabled = "管理员关闭了密码登录"
|
||||
errUsernameOrPasswordWrong = "用户名或密码错误"
|
||||
errBannedAccount = "用户已被封禁"
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// 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("认证源不存在")
|
||||
}
|
||||
Reference in New Issue
Block a user