Files
2026-10-13 05:31:30 +08:00

589 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/outbound"
"gorm.io/gorm"
)
const linuxDOSettingKey = "linuxdo_oauth"
var oauthUsernameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
type LinuxDOSettingRequest struct {
Enabled bool `json:"enabled"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AuthorizationURL string `json:"authorizationUrl"`
TokenURL string `json:"tokenUrl"`
UserInfoURL string `json:"userInfoUrl"`
RedirectURL string `json:"redirectUrl"`
Scopes []string `json:"scopes"`
ClientAuthMethod string `json:"clientAuthMethod"`
SubjectField string `json:"subjectField"`
UsernameField string `json:"usernameField"`
DisplayNameField string `json:"displayNameField"`
EmailField string `json:"emailField"`
AvatarField string `json:"avatarField"`
}
type PublicLinuxDOSetting struct {
Enabled bool `json:"enabled"`
ClientID string `json:"clientId"`
HasClientSecret bool `json:"hasClientSecret"`
AuthorizationURL string `json:"authorizationUrl"`
TokenURL string `json:"tokenUrl"`
UserInfoURL string `json:"userInfoUrl"`
RedirectURL string `json:"redirectUrl"`
Scopes []string `json:"scopes"`
ClientAuthMethod string `json:"clientAuthMethod"`
SubjectField string `json:"subjectField"`
UsernameField string `json:"usernameField"`
DisplayNameField string `json:"displayNameField"`
EmailField string `json:"emailField"`
AvatarField string `json:"avatarField"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type linuxDOSettingValue struct {
Enabled bool `json:"enabled"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AuthorizationURL string `json:"authorizationUrl"`
TokenURL string `json:"tokenUrl"`
UserInfoURL string `json:"userInfoUrl"`
RedirectURL string `json:"redirectUrl"`
Scopes []string `json:"scopes"`
ClientAuthMethod string `json:"clientAuthMethod"`
SubjectField string `json:"subjectField"`
UsernameField string `json:"usernameField"`
DisplayNameField string `json:"displayNameField"`
EmailField string `json:"emailField"`
AvatarField string `json:"avatarField"`
}
type LinuxDOCallbackResult struct {
Session *AuthSessionResult
Next string
Reauthenticated bool
}
func (s *Service) AdminLinuxDOSetting(actor *model.User) (*PublicLinuxDOSetting, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
setting, value, err := s.readLinuxDOSetting()
if err != nil {
return nil, err
}
return publicLinuxDOSetting(setting, value), nil
}
func (s *Service) UpdateLinuxDOSetting(actor *model.User, req LinuxDOSettingRequest) (*PublicLinuxDOSetting, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
currentSetting, current, err := s.readLinuxDOSetting()
if err != nil {
return nil, err
}
next := normalizeLinuxDOSetting(linuxDOSettingValue{
Enabled: req.Enabled, ClientID: req.ClientID, ClientSecret: req.ClientSecret,
AuthorizationURL: req.AuthorizationURL, TokenURL: req.TokenURL, UserInfoURL: req.UserInfoURL,
RedirectURL: req.RedirectURL, Scopes: req.Scopes, ClientAuthMethod: req.ClientAuthMethod,
SubjectField: req.SubjectField, UsernameField: req.UsernameField, DisplayNameField: req.DisplayNameField,
EmailField: req.EmailField, AvatarField: req.AvatarField,
})
if next.ClientSecret == "" {
next.ClientSecret = current.ClientSecret
}
if err := validateLinuxDOSetting(next); err != nil {
return nil, err
}
stored := next
stored.ClientSecret, err = s.host.EncryptSecret(next.ClientSecret)
if err != nil {
return nil, err
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, err
}
setting := model.SystemSetting{Key: linuxDOSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
if currentSetting != nil {
setting.CreatedAt = currentSetting.CreatedAt
}
if err := s.repo.SaveSystemSetting(&setting); err != nil {
return nil, err
}
return publicLinuxDOSetting(&setting, next), nil
}
func (s *Service) LinuxDOEnabled() bool {
_, setting, err := s.readLinuxDOSetting()
return err == nil && setting.Enabled
}
func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string, error) {
count, err := s.repo.UserCount()
if err != nil {
return "", err
}
if count == 0 {
return "", kernel.Forbidden("请先创建本地管理员账号,再开放 Linux.do 登录")
}
_, setting, err := s.readLinuxDOSetting()
if err != nil {
return "", err
}
if !setting.Enabled {
return "", kernel.Forbidden("Linux.do 登录尚未启用")
}
return s.beginLinuxDOAuthorization(setting, &model.OAuthState{NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms})
}
func (s *Service) BeginLinuxDOReauthentication(actor *model.User, cookie string) (string, error) {
user, session, err := s.profileSession(actor, cookie)
if err != nil {
return "", err
}
if _, err := s.repo.UserIdentityForUser(user.ID, "linuxdo"); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", kernel.Forbidden("该账号没有关联 Linux.do 身份")
}
return "", err
}
_, setting, err := s.readLinuxDOSetting()
if err != nil {
return "", err
}
if !setting.Enabled {
return "", kernel.Forbidden("Linux.do 登录尚未启用")
}
return s.beginLinuxDOAuthorization(setting, &model.OAuthState{NextPath: "/settings?section=profile&reauth=success", ReauthUserID: user.ID, ReauthSessionID: session.ID})
}
func (s *Service) LinuxDOReauthenticationState(stateValue string) (bool, error) {
if strings.TrimSpace(stateValue) == "" {
return false, nil
}
return s.repo.LinuxDOReauthenticationState(HashToken(stateValue))
}
func (s *Service) beginLinuxDOAuthorization(setting linuxDOSettingValue, record *model.OAuthState) (string, error) {
state := RandomToken()
verifier := RandomToken()
challengeBytes := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
record.ID, record.Provider, record.StateHash, record.CodeVerifier, record.ExpiresAt = kernel.NewID(), "linuxdo", HashToken(state), verifier, time.Now().Add(10*time.Minute)
if err := s.repo.CreateOAuthState(record); err != nil {
return "", err
}
authorizeURL, err := url.Parse(setting.AuthorizationURL)
if err != nil {
return "", err
}
query := authorizeURL.Query()
query.Set("client_id", setting.ClientID)
query.Set("redirect_uri", setting.RedirectURL)
query.Set("response_type", "code")
if len(setting.Scopes) > 0 {
query.Set("scope", strings.Join(setting.Scopes, " "))
}
query.Set("state", state)
query.Set("code_challenge", challenge)
query.Set("code_challenge_method", "S256")
authorizeURL.RawQuery = query.Encode()
return authorizeURL.String(), nil
}
func (s *Service) CompleteLinuxDOLogin(stateValue string, code string, cookieValue string, contexts ...LoginContext) (result *LinuxDOCallbackResult, err error) {
var context LoginContext
if len(contexts) > 0 {
context = contexts[0]
}
event := NewLoginLog("linuxdo", "login", context)
logFailure := true
defer func() {
if err != nil && logFailure {
err = s.recordLoginFailure(event, err)
}
}()
if strings.TrimSpace(stateValue) != "" {
reauth, stateErr := s.LinuxDOReauthenticationState(stateValue)
if stateErr != nil {
return nil, stateErr
}
logFailure = !reauth
}
if strings.TrimSpace(stateValue) == "" || strings.TrimSpace(code) == "" {
return nil, kernel.BadAuthRequest("Linux.do 登录回调缺少必要参数")
}
state, err := s.repo.ConsumeOAuthState("linuxdo", HashToken(stateValue))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, kernel.BadAuthRequest("Linux.do 登录状态无效或已过期")
}
if err != nil {
return nil, err
}
logFailure = state.ReauthUserID == "" && state.ReauthSessionID == ""
_, setting, err := s.readLinuxDOSetting()
if err != nil {
return nil, err
}
if !setting.Enabled {
return nil, kernel.Forbidden("Linux.do 登录尚未启用")
}
var reauthUser *model.User
var reauthSession *model.AuthSession
if state.ReauthUserID != "" || state.ReauthSessionID != "" {
if cookieValue == "" {
return nil, kernel.Unauthorized("重新验证需要当前登录会话")
}
reauthUser, reauthSession, err = s.profileSession(&model.User{ID: state.ReauthUserID}, cookieValue)
if err != nil {
return nil, err
}
if reauthSession.ID != state.ReauthSessionID {
return nil, kernel.Unauthorized("重新验证与当前登录会话不匹配")
}
}
accessToken, err := exchangeLinuxDOCode(setting, code, state.CodeVerifier)
if err != nil {
return nil, err
}
profile, err := fetchLinuxDOProfile(setting, accessToken)
if err != nil {
return nil, err
}
subject := profileString(profile, setting.SubjectField)
if subject == "" {
return nil, errors.New("Linux.do 用户信息缺少稳定用户 ID")
}
if reauthUser != nil {
if err := s.repo.ReauthenticateLinuxDOSession(reauthUser, reauthSession, subject, time.Now()); err != nil {
return nil, profileWriteError(err)
}
return &LinuxDOCallbackResult{Next: "/settings?section=profile&reauth=success", Reauthenticated: true}, nil
}
providerUsername := profileString(profile, setting.UsernameField)
displayName := kernel.FirstNonEmpty(profileString(profile, setting.DisplayNameField), providerUsername, "Linux.do 用户")
avatarURL := profileString(profile, setting.AvatarField)
identity, err := s.repo.UserIdentity("linuxdo", subject)
var user *model.User
if err == nil {
user, err = s.repo.User(identity.UserID)
if err != nil {
return nil, err
}
identity.ProviderUsername = providerUsername
identity.AvatarURL = avatarURL
identity.UpdatedAt = time.Now()
if err := s.repo.Save(identity); err != nil {
return nil, err
}
} else if errors.Is(err, gorm.ErrRecordNotFound) {
event.Source = "registration"
policy, policyErr := s.verificationPolicy()
if policyErr != nil {
return nil, policyErr
}
if policy.SMSAndEmailRegistration {
return nil, kernel.Forbidden("平台要求短信和邮箱验证,请先通过注册页面创建账号;第三方登录不能绕过双重验证")
}
registrationEnabled, settingErr := s.RegistrationEnabled()
if settingErr != nil {
return nil, settingErr
}
if !registrationEnabled {
return nil, kernel.Forbidden("管理员未开放新用户注册")
}
if !state.AcceptedTerms {
return nil, kernel.BadAuthRequest("请先同意" + s.AgreementTitleForMessage())
}
user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL)
if err != nil {
return nil, err
}
if err := s.repo.CreateOAuthUser(user, identity); err != nil {
return nil, err
}
} else {
return nil, err
}
loginLogUser(event, user)
if user.Status != model.UserStatusActive {
return nil, kernel.Forbidden("该账号已被禁用")
}
if err := s.host.EnsureSignupBonus(user.ID); err != nil {
return nil, err
}
now := time.Now()
user.LastLoginAt = &now
user.UpdatedAt = now
if err := s.repo.UpdateUserLoginTime(user.ID, now); err != nil {
return nil, err
}
session, err := s.createAuthSession(user, event)
if err != nil {
return nil, err
}
return &LinuxDOCallbackResult{Session: session, Next: safeOAuthNext(state.NextPath)}, nil
}
func (s *Service) createLinuxDOUser(subject string, providerUsername string, displayName string, email string, avatarURL string) (*model.User, *model.UserIdentity, error) {
base := oauthUsernameSanitizer.ReplaceAllString(strings.TrimSpace(providerUsername), "_")
base = strings.Trim(base, "_-")
if len(base) < 3 {
base = "linuxdo_" + shortSubject(subject)
}
if len(base) > 24 {
base = base[:24]
}
username := base
if existing, err := s.repo.UserByUsername(username); err == nil && existing != nil {
username = kernel.TruncateRunes(base, 23) + "_" + shortSubject(subject)
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, err
}
email = NormalizeEmail(email)
if email != "" {
if ValidateEmail(email) != nil {
email = ""
} else if existing, err := s.repo.UserByEmail(email); err == nil && existing != nil {
// 相同邮箱不自动合并身份,避免第三方邮箱状态不明导致账号接管。
email = ""
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, err
}
}
user := &model.User{ID: kernel.NewID(), Username: username, Email: email, DisplayName: NormalizeDisplayName(displayName, username), Role: model.UserRoleUser, Status: model.UserStatusActive}
identity := &model.UserIdentity{ID: kernel.NewID(), UserID: user.ID, Provider: "linuxdo", Subject: subject, ProviderUsername: providerUsername, AvatarURL: avatarURL}
return user, identity, nil
}
func (s *Service) readLinuxDOSetting() (*model.SystemSetting, linuxDOSettingValue, error) {
setting, err := s.repo.SystemSetting(linuxDOSettingKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, defaultLinuxDOSetting(), nil
}
if err != nil {
return nil, linuxDOSettingValue{}, err
}
value := defaultLinuxDOSetting()
if strings.TrimSpace(setting.ValueJSON) != "" {
if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil {
return nil, linuxDOSettingValue{}, errors.New("Linux.do OAuth 配置格式无效")
}
}
value.ClientSecret, err = s.host.DecryptSecret(value.ClientSecret)
if err != nil {
return nil, linuxDOSettingValue{}, err
}
return setting, normalizeLinuxDOSetting(value), nil
}
func validateLinuxDOSetting(value linuxDOSettingValue) error {
if !value.Enabled {
return nil
}
if value.ClientID == "" || value.ClientSecret == "" || value.AuthorizationURL == "" || value.TokenURL == "" || value.UserInfoURL == "" || value.RedirectURL == "" {
return kernel.BadAuthRequest("启用 Linux.do 登录前请完整填写 Client、端点和回调配置")
}
for _, rawURL := range []string{value.AuthorizationURL, value.TokenURL, value.UserInfoURL} {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return kernel.BadAuthRequest("Linux.do 授权、Token 和用户信息地址必须是有效的 HTTPS URL")
}
}
redirectURL, err := url.Parse(value.RedirectURL)
if err != nil || redirectURL.Host == "" || (redirectURL.Scheme != "https" && !(redirectURL.Scheme == "http" && isLoopbackOAuthHost(redirectURL.Hostname()))) {
return kernel.BadAuthRequest("Linux.do 回调地址必须使用 HTTPS,本地回环地址可使用 HTTP")
}
if value.ClientAuthMethod != "client_secret_post" && value.ClientAuthMethod != "client_secret_basic" {
return kernel.BadAuthRequest("请选择有效的 OAuth 客户端鉴权方式")
}
return nil
}
func isLoopbackOAuthHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
func normalizeLinuxDOSetting(value linuxDOSettingValue) linuxDOSettingValue {
value.ClientID = strings.TrimSpace(value.ClientID)
value.ClientSecret = strings.TrimSpace(value.ClientSecret)
value.AuthorizationURL = strings.TrimSpace(value.AuthorizationURL)
value.TokenURL = strings.TrimSpace(value.TokenURL)
value.UserInfoURL = strings.TrimSpace(value.UserInfoURL)
value.RedirectURL = strings.TrimSpace(value.RedirectURL)
value.Scopes = kernel.UniqueNonEmpty(value.Scopes)
value.ClientAuthMethod = strings.TrimSpace(value.ClientAuthMethod)
value.SubjectField = strings.TrimSpace(value.SubjectField)
value.UsernameField = strings.TrimSpace(value.UsernameField)
value.DisplayNameField = strings.TrimSpace(value.DisplayNameField)
value.EmailField = strings.TrimSpace(value.EmailField)
value.AvatarField = strings.TrimSpace(value.AvatarField)
if value.ClientAuthMethod == "" {
value.ClientAuthMethod = "client_secret_post"
}
if value.SubjectField == "" {
value.SubjectField = "id"
}
if value.UsernameField == "" {
value.UsernameField = "username"
}
if value.DisplayNameField == "" {
value.DisplayNameField = "name"
}
if value.EmailField == "" {
value.EmailField = "email"
}
if value.AvatarField == "" {
value.AvatarField = "avatar_url"
}
return value
}
func defaultLinuxDOSetting() linuxDOSettingValue {
return normalizeLinuxDOSetting(linuxDOSettingValue{})
}
func publicLinuxDOSetting(setting *model.SystemSetting, value linuxDOSettingValue) *PublicLinuxDOSetting {
result := &PublicLinuxDOSetting{
Enabled: value.Enabled, ClientID: value.ClientID, HasClientSecret: value.ClientSecret != "",
AuthorizationURL: value.AuthorizationURL, TokenURL: value.TokenURL, UserInfoURL: value.UserInfoURL,
RedirectURL: value.RedirectURL, Scopes: value.Scopes, ClientAuthMethod: value.ClientAuthMethod,
SubjectField: value.SubjectField, UsernameField: value.UsernameField, DisplayNameField: value.DisplayNameField,
EmailField: value.EmailField, AvatarField: value.AvatarField,
}
if setting != nil {
result.UpdatedBy = setting.UpdatedBy
result.CreatedAt = setting.CreatedAt
result.UpdatedAt = setting.UpdatedAt
}
return result
}
func exchangeLinuxDOCode(setting linuxDOSettingValue, code string, verifier string) (string, error) {
if _, err := outbound.ValidateOutboundURL(setting.TokenURL); err != nil {
return "", err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {setting.ClientID},
"redirect_uri": {setting.RedirectURL},
"code_verifier": {verifier},
}
if setting.ClientAuthMethod == "client_secret_post" {
form.Set("client_secret", setting.ClientSecret)
}
req, err := http.NewRequest(http.MethodPost, setting.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if setting.ClientAuthMethod == "client_secret_basic" {
req.SetBasicAuth(setting.ClientID, setting.ClientSecret)
}
resp, err := outbound.OutboundHTTPClient(20 * time.Second).Do(req)
if err != nil {
return "", fmt.Errorf("Linux.do Token 请求失败:%w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("Linux.do Token 请求失败:HTTP %d", resp.StatusCode)
}
var payload struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.AccessToken == "" {
return "", errors.New("Linux.do Token 响应无效")
}
return payload.AccessToken, nil
}
func fetchLinuxDOProfile(setting linuxDOSettingValue, accessToken string) (map[string]any, error) {
if _, err := outbound.ValidateOutboundURL(setting.UserInfoURL); err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, setting.UserInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := outbound.OutboundHTTPClient(20 * time.Second).Do(req)
if err != nil {
return nil, fmt.Errorf("Linux.do 用户信息请求失败:%w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Linux.do 用户信息请求失败:HTTP %d", resp.StatusCode)
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
var profile map[string]any
if err := decoder.Decode(&profile); err != nil {
return nil, errors.New("Linux.do 用户信息响应无效")
}
return profile, nil
}
func profileString(profile map[string]any, field string) string {
var value any = profile
for _, segment := range strings.Split(field, ".") {
object, ok := value.(map[string]any)
if !ok {
return ""
}
value, ok = object[segment]
if !ok || value == nil {
return ""
}
}
return strings.TrimSpace(fmt.Sprint(value))
}
func safeOAuthNext(value string) string {
value = strings.TrimSpace(value)
parsed, err := url.Parse(value)
if value == "" || err != nil || parsed.IsAbs() || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") {
return "/create"
}
return parsed.RequestURI()
}
func shortSubject(value string) string {
sum := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", sum[:4])
}