feat(auth): 注册协议 - 增加同意校验并预留影策协议正文
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
186
backend/internal/auth/auth_registration_test.go
Normal file
186
backend/internal/auth/auth_registration_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
backend/internal/database/oauth_state_accepted_terms_test.go
Normal file
50
backend/internal/database/oauth_state_accepted_terms_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user