Files
ttstd-infinite-canvas/backend/internal/auth/linuxdo.go
✨await 672bb0be73 feat(auth): 注册服务协议 - 名称与条款改为后台可配置并同步注册页展示
后端:
- RegistrationSettingRequest 协议字段改为可选指针,注册开关单独保存不再覆盖协议正文
- 新增 RegistrationAgreement / AgreementTitleForMessage:标题未配置时跟随品牌名生成,读取失败返回空标题而非猜一个不存在的协议名
- PublicAuthSettings 下发 agreementTitle / agreementContent,首用户分支同样携带
- 新增 registration_agreement_test.go 覆盖品牌名兜底、存储合并与失败降级

前端:
- 注册页协议标题与条款改为消费后台配置;读取失败时隐藏协议入口并支持重新读取
- 验证码获取按钮内联到输入框右侧,联系方式为空即禁用;sms_email 双通道合并为单输入框
- 协议弹窗重排:条目标题独立成行加重、空态虚线提示、底部补充同意说明;深浅主题适配
- 协议勾选行改为状态化区域,复选框适配 AntD 6 结构(方框在 .ant-checkbox 本体,修正深色主题白底白勾)
- 后台「服务协议名称与条款」区块补齐表单内边距并对齐区块头/页脚,协议名称限宽

文档:http-api.mdx 更新协议字段语义与失败降级说明;pending-test.mdx 增加手工验证清单
2026-09-24 16:56:44 +08:00

515 lines
18 KiB
Go
Raw 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"
"infinite-canvas/backend/internal/kernel"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/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
}
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 登录尚未启用")
}
state := RandomToken()
verifier := RandomToken()
challengeBytes := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
if err := s.repo.CreateOAuthState(&model.OAuthState{
ID: kernel.NewID(), Provider: "linuxdo", StateHash: HashToken(state), CodeVerifier: verifier,
NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms, ExpiresAt: time.Now().Add(10 * time.Minute),
}); 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) (*LinuxDOCallbackResult, error) {
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
}
_, setting, err := s.readLinuxDOSetting()
if err != nil {
return nil, err
}
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")
}
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) {
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
}
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.Save(user); err != nil {
return nil, err
}
s.host.RecordActivity(user.ID, "login", 1)
session, err := s.createAuthSession(user)
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])
}