feat(auth): 注册协议 - 增加同意校验并预留影策协议正文
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user