feat(auth): 注册协议 - 增加同意校验并预留影策协议正文

This commit is contained in:
陈泽
2026-09-20 22:57:31 +08:00
parent 0e3fb79ba6
commit 316386a2bd
16 changed files with 372 additions and 30 deletions

View File

@@ -196,8 +196,8 @@ func (s *Service) LinuxDOEnabled() bool {
return s.authDomain().LinuxDOEnabled()
}
func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) {
return s.authDomain().BeginLinuxDOLogin(nextPath)
func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string, error) {
return s.authDomain().BeginLinuxDOLogin(nextPath, acceptedTerms)
}
func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) {

View File

@@ -28,11 +28,12 @@ var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
type AuthError = kernel.AppError
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
EmailCode string `json:"emailCode"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
EmailCode string `json:"emailCode"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
AcceptedTerms bool `json:"acceptedTerms"`
}
type LoginRequest struct {
@@ -82,6 +83,9 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
}
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if !req.AcceptedTerms {
return nil, kernel.BadAuthRequest("请先同意影策服务协议")
}
username := NormalizeUsername(req.Username)
email := NormalizeEmail(req.Email)
displayName := NormalizeDisplayName(req.DisplayName, username)

View File

@@ -0,0 +1,186 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
func newRegistrationTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
svc, db := newPasswordResetTestService(t)
if err := db.AutoMigrate(&model.UserIdentity{}, &model.OAuthState{}, &model.CreditAccount{}); err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
return svc, db
}
func TestRegisterRequiresAcceptedTermsBeforeWriting(t *testing.T) {
for _, payload := range []string{
`{"username":"new-user","password":"password"}`,
`{"username":"new-user","password":"password","acceptedTerms":false}`,
} {
svc, db := newRegistrationTestService(t)
var req RegisterRequest
if err := json.Unmarshal([]byte(payload), &req); err != nil {
t.Fatal(err)
}
_, err := svc.Register(req)
var authErr *AuthError
if !errors.As(err, &authErr) || authErr.Status != 400 || authErr.Message != "请先同意影策服务协议" {
t.Fatalf("Register() error = %v", err)
}
for _, entity := range []any{&model.User{}, &model.AuthSession{}} {
var count int64
if err := db.Model(entity).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("unaccepted registration wrote %T: count=%d err=%v", entity, count, err)
}
}
}
}
func TestRegisterAcceptedTermsCreatesFirstAdmin(t *testing.T) {
svc, _ := newRegistrationTestService(t)
result, err := svc.Register(RegisterRequest{Username: "new-user", Password: "password", AcceptedTerms: true})
if err != nil {
t.Fatal(err)
}
if result.User.Username != "new-user" || result.User.Role != model.UserRoleAdmin || result.Session == "" {
t.Fatalf("Register() result = %#v", result)
}
}
func TestRegisterAcceptedTermsCreatesEmailUser(t *testing.T) {
svc, db := newRegistrationTestService(t)
if err := db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil {
t.Fatal(err)
}
var code string
svc.SetMailSender(func(_ EmailSettingValue, _, _ string, body string) error {
code = codeFromEmailBody(body)
return nil
})
if err := svc.SendRegistrationEmailCode("member@example.com"); err != nil {
t.Fatal(err)
}
result, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", EmailCode: code, Password: "password", AcceptedTerms: true})
if err != nil {
t.Fatal(err)
}
if result.User.Role != model.UserRoleUser || result.Session == "" {
t.Fatalf("Register() result = %#v", result)
}
}
func TestLinuxDORegistrationAgreement(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var payload any
switch r.URL.Path {
case "/token":
payload = map[string]string{"access_token": "test-access-token"}
case "/profile":
payload = map[string]string{"id": "42", "username": "linux-user", "name": "Linux User"}
default:
http.NotFound(w, r)
return
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Error(err)
}
}))
defer server.Close()
for _, tc := range []struct {
name string
accepted bool
existing bool
registration bool
wantError string
}{
{name: "new user without agreement", registration: true, wantError: "请先同意影策服务协议"},
{name: "new user with agreement", accepted: true, registration: true},
{name: "existing user without agreement", existing: true, registration: true},
{name: "existing user with registration closed", existing: true},
{name: "agreement does not bypass registration closure", accepted: true, wantError: "管理员未开放新用户注册"},
} {
t.Run(tc.name, func(t *testing.T) {
svc, db := newRegistrationTestService(t)
if err := db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}).Error; err != nil {
t.Fatal(err)
}
setting, err := json.Marshal(linuxDOSettingValue{Enabled: true, ClientID: "client", AuthorizationURL: server.URL + "/authorize", TokenURL: server.URL + "/token", UserInfoURL: server.URL + "/profile", RedirectURL: "https://example.com/callback"})
if err != nil {
t.Fatal(err)
}
for _, value := range []model.SystemSetting{
{Key: linuxDOSettingKey, ValueJSON: string(setting)},
{Key: registrationSettingKey, ValueJSON: `{"enabled":` + strconv.FormatBool(tc.registration) + `}`},
} {
if err := db.Create(&value).Error; err != nil {
t.Fatal(err)
}
}
if tc.existing {
if err := svc.repo.CreateOAuthUser(
&model.User{ID: "existing-user", Username: "linux-user", Role: model.UserRoleUser, Status: model.UserStatusActive},
&model.UserIdentity{ID: "identity", UserID: "existing-user", Provider: "linuxdo", Subject: "42"},
); err != nil {
t.Fatal(err)
}
}
target, err := svc.BeginLinuxDOLogin("/create", tc.accepted)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(target)
if err != nil {
t.Fatal(err)
}
stateValue := parsed.Query().Get("state")
var saved model.OAuthState
if err := db.Where("state_hash = ?", HashToken(stateValue)).First(&saved).Error; err != nil || saved.AcceptedTerms != tc.accepted {
t.Fatalf("saved consent = %v, err=%v", saved.AcceptedTerms, err)
}
result, err := svc.CompleteLinuxDOLogin(stateValue, "test-code")
wantUsers, wantRecords := int64(2), int64(1)
if tc.wantError != "" {
var authErr *AuthError
if !errors.As(err, &authErr) || authErr.Message != tc.wantError {
t.Fatalf("CompleteLinuxDOLogin() error = %v", err)
}
wantUsers, wantRecords = 1, 0
} else if err != nil || result == nil || result.Session.Session == "" || result.Next != "/create" {
t.Fatalf("CompleteLinuxDOLogin() result=%#v err=%v", result, err)
}
if count, err := svc.repo.UserCount(); err != nil || count != wantUsers {
t.Fatalf("user count=%d want=%d err=%v", count, wantUsers, err)
}
for _, entity := range []any{&model.UserIdentity{}, &model.CreditAccount{}, &model.AuthSession{}} {
var count int64
if err := db.Model(entity).Count(&count).Error; err != nil || count != wantRecords {
t.Fatalf("%T count=%d want=%d err=%v", entity, count, wantRecords, err)
}
}
if _, err := svc.CompleteLinuxDOLogin(stateValue, "test-code"); err == nil {
t.Fatal("consumed OAuth state was accepted twice")
}
})
}
}

View File

@@ -43,7 +43,7 @@ func TestRegistrationDomainPolicyChecksSendingAndRegistration(t *testing.T) {
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"gmail.com"}}); err != nil {
t.Fatal(err)
}
if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode}); err == nil || !strings.Contains(err.Error(), "白名单") {
if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode, AcceptedTerms: true}); err == nil || !strings.Contains(err.Error(), "白名单") {
t.Fatalf("registration must recheck current domain policy: %v", err)
}
count, err := svc.repo.UserCount()

View File

@@ -140,7 +140,7 @@ func (s *Service) LinuxDOEnabled() bool {
return err == nil && setting.Enabled
}
func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) {
func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string, error) {
count, err := s.repo.UserCount()
if err != nil {
return "", err
@@ -161,7 +161,7 @@ func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) {
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), ExpiresAt: time.Now().Add(10 * time.Minute),
NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms, ExpiresAt: time.Now().Add(10 * time.Minute),
}); err != nil {
return "", err
}
@@ -234,6 +234,9 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO
if !registrationEnabled {
return nil, kernel.Forbidden("管理员未开放新用户注册")
}
if !state.AcceptedTerms {
return nil, kernel.BadAuthRequest("请先同意影策服务协议")
}
user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL)
if err != nil {
return nil, err

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 31
const CurrentSchemaVersion int64 = 32
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -105,6 +105,14 @@ var schemaMigrations = []migration{
{version: 31, name: "tool_favorites", checksum: "sha256:tool-favorites-v31", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.ToolFavorite{})
}},
{version: 32, name: "oauth_state_accepted_terms", checksum: "sha256:oauth-state-accepted-terms-v32", apply: migrateOAuthStateAcceptedTerms},
}
func migrateOAuthStateAcceptedTerms(tx *gorm.DB) error {
if tx.Migrator().HasColumn(&model.OAuthState{}, "AcceptedTerms") {
return nil
}
return tx.Migrator().AddColumn(&model.OAuthState{}, "AcceptedTerms")
}
func migrateChannelCreditCost(tx *gorm.DB) error {

View File

@@ -446,7 +446,7 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) {
if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil {
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}, &model.OAuthState{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
@@ -490,7 +490,7 @@ func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil {
if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}, &model.OAuthState{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&schemaMigration{}); err != nil {

View File

@@ -0,0 +1,50 @@
package database
import (
"testing"
"infinite-canvas/backend/internal/model"
)
func TestOAuthStateAcceptedTermsMigration(t *testing.T) {
db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"})
if err != nil {
t.Fatal(err)
}
for _, sql := range []string{
`CREATE TABLE o_auth_states (id TEXT PRIMARY KEY, provider TEXT, state_hash TEXT, code_verifier TEXT, next_path TEXT)`,
`INSERT INTO o_auth_states VALUES ('legacy', 'linuxdo', 'old-hash', 'old-verifier', '/create')`,
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
for i := 0; i < 2; i++ {
if err := migrateOAuthStateAcceptedTerms(db); err != nil {
t.Fatal(err)
}
}
var state model.OAuthState
if err := db.First(&state, "id = ?", "legacy").Error; err != nil {
t.Fatal(err)
}
if state.AcceptedTerms || state.Provider != "linuxdo" || state.StateHash != "old-hash" || state.CodeVerifier != "old-verifier" || state.NextPath != "/create" {
t.Fatalf("migration changed existing state: %#v", state)
}
if err := db.Exec(`INSERT INTO o_auth_states (id) VALUES ('new-state')`).Error; err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&model.OAuthState{}).Where("accepted_terms = ?", false).Count(&count).Error; err != nil || count != 2 {
t.Fatalf("default consent count=%d err=%v", count, err)
}
if err := db.Model(&state).Update("accepted_terms", true).Error; err != nil {
t.Fatal(err)
}
if err := migrateOAuthStateAcceptedTerms(db); err != nil {
t.Fatal(err)
}
if err := db.First(&state, "id = ?", "legacy").Error; err != nil || !state.AcceptedTerms {
t.Fatalf("repeat migration lost saved consent: %#v err=%v", state, err)
}
}

View File

@@ -139,7 +139,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
if !enforceRateLimit(c, "linuxdo-start:"+c.ClientIP(), 20, 10*time.Minute) {
return
}
target, err := svc.BeginLinuxDOLogin(c.Query("next"))
acceptedTerms := c.Query("acceptedTerms") == "true"
target, err := svc.BeginLinuxDOLogin(c.Query("next"), acceptedTerms)
if err != nil {
failService(c, err)
return

View File

@@ -36,14 +36,15 @@ type UserIdentity struct {
}
type OAuthState struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
Provider string `json:"provider" gorm:"index;size:32"`
StateHash string `json:"-" gorm:"uniqueIndex;size:64"`
CodeVerifier string `json:"-" gorm:"size:160"`
NextPath string `json:"nextPath"`
ExpiresAt time.Time `json:"expiresAt" gorm:"index"`
UsedAt *time.Time `json:"usedAt" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id" gorm:"primaryKey;size:36"`
Provider string `json:"provider" gorm:"index;size:32"`
StateHash string `json:"-" gorm:"uniqueIndex;size:64"`
CodeVerifier string `json:"-" gorm:"size:160"`
NextPath string `json:"nextPath"`
AcceptedTerms bool `json:"acceptedTerms" gorm:"not null;default:false"`
ExpiresAt time.Time `json:"expiresAt" gorm:"index"`
UsedAt *time.Time `json:"usedAt" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
}
type EmailVerificationCode struct {