feat(user): SMS verification-code sign-in and phone binding (#230)

Generic HTTP SMS gateway (endpoint/method/headers/body templates with
{phone}/{code} placeholders, SSRF-guarded outbound request) backs a new
users.phone column and three verification scenes:

- POST /session/sms/send — IP rate-limited, login-CAPTCHA gated, 60s
  per-phone resend throttle, single-use 6-digit codes (5-min TTL)
- POST /session/sms/login — signs in by phone+code, auto-provisions a
  synthetic sms_*@sms.local account when sms_register_enabled is on, and
  returns the same 2FA continuation as password login
- POST /user/reset_sms — password reset for phone-bound accounts
- PUT|DELETE /user/setting/phone — bind/unbind behind the
  security-info-write scope

User settings expose only a masked phone. Frontend gains an SMS login
phase + entry button (gated by site config sms_enabled), a
reset-via-SMS mode in forgot password, a phone-binding section in
security settings, and an SMS gateway accordion in admin UserSession.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
parent c7a1b7f13d
commit 413fac06f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -227,6 +227,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Weighted policy selection (upstream #2178 item 2) — `GroupSetting.WeightedPolicies` spreads uploads across the group's allowed policies by free capacity: `pickByFreeCapacity` picks the member with the most remaining `MaxTotalSize` headroom that fits the file (uncapped/suspended members not weighed); explicit directory/user preferences still win; size-aware `getPreferredPolicyForSize` wired into both upload paths; admin group editor gains a switch, en+zh locales - [x] Weighted policy selection (upstream #2178 item 2) — `GroupSetting.WeightedPolicies` spreads uploads across the group's allowed policies by free capacity: `pickByFreeCapacity` picks the member with the most remaining `MaxTotalSize` headroom that fits the file (uncapped/suspended members not weighed); explicit directory/user preferences still win; size-aware `getPreferredPolicyForSize` wired into both upload paths; admin group editor gains a switch, en+zh locales
- [x] Download source typing + torrent bomb guard (upstream #2178 离线下载) — `CreateDownloadTask` distinguishes plain URLs from BitTorrent sources: `src_file` must name a `.torrent`, `magnet:` links auto-pick a BT-capable provider (qBittorrent preferred, aria2 fallback) and fail fast when only non-BT nodes exist; explicit `provider=ytdlp` with a torrent source is rejected; `validateFiles` caps selected files per task at `maxDownloadFiles` (10k) with `queue.CriticalErr` so crafted torrents cannot flood the entity table - [x] Download source typing + torrent bomb guard (upstream #2178 离线下载) — `CreateDownloadTask` distinguishes plain URLs from BitTorrent sources: `src_file` must name a `.torrent`, `magnet:` links auto-pick a BT-capable provider (qBittorrent preferred, aria2 fallback) and fail fast when only non-BT nodes exist; explicit `provider=ytdlp` with a torrent source is rejected; `validateFiles` caps selected files per task at `maxDownloadFiles` (10k) with `queue.CriticalErr` so crafted torrents cannot flood the entity table
- [x] WeChat scan login (upstream #2729 item 2) — `GET /session/wechat/login` redirects to `open.weixin.qq.com/connect/qrconnect` (scope `snsapi_login`, `#wechat_redirect` fragment); callback exchanges the code at `sns/oauth2/access_token` and binds by unionid (openid fallback); shares the single-use SSO state/ticket machinery and `sso_binding` table; provisioned accounts use synthetic `@connect.wechat.local` addresses with nickname from `/sns/userinfo`; account linking via `?link=1` + unbind via the shared provider route; admin UserSession section gains a WeChat accordion (enabled/AppID/AppSecret/register-enabled, callback URL shown); login page + security settings gain WeChat buttons; en+zh locales - [x] WeChat scan login (upstream #2729 item 2) — `GET /session/wechat/login` redirects to `open.weixin.qq.com/connect/qrconnect` (scope `snsapi_login`, `#wechat_redirect` fragment); callback exchanges the code at `sns/oauth2/access_token` and binds by unionid (openid fallback); shares the single-use SSO state/ticket machinery and `sso_binding` table; provisioned accounts use synthetic `@connect.wechat.local` addresses with nickname from `/sns/userinfo`; account linking via `?link=1` + unbind via the shared provider route; admin UserSession section gains a WeChat accordion (enabled/AppID/AppSecret/register-enabled, callback URL shown); login page + security settings gain WeChat buttons; en+zh locales
- [x] SMS verification-code sign-in + phone binding — generic HTTP SMS gateway (`sms_*` settings: endpoint/method/headers/body template with `{phone}`/`{code}` placeholders, SSRF-guarded outbound call); `users.phone` unique optional column; KV-stored 6-digit codes (5-min TTL, single-use, 60s resend throttle) across `login`/`bind`/`reset` scenes; `POST /session/sms/send` (IP rate-limit + login-CAPTCHA gate) / `POST /session/sms/login` (auto-provisions synthetic `sms_*@sms.local` accounts when enabled, 2FA continuation preserved) / `POST /user/reset_sms` / `PUT|DELETE /user/setting/phone`; masked phone in user settings response; login page gains an SMS phase + reset-via-SMS mode in forgot password, security settings gain a phone-binding section, admin UserSession gains an SMS gateway accordion; en+zh locales
## 6. Phase D — desktop, all platforms ## 6. Phase D — desktop, all platforms

File diff suppressed because one or more lines are too long

@ -781,6 +781,7 @@ var (
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}}, {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "email", Type: field.TypeString, Unique: true, Size: 100}, {Name: "email", Type: field.TypeString, Unique: true, Size: 100},
{Name: "phone", Type: field.TypeString, Unique: true, Nullable: true, Size: 20},
{Name: "nick", Type: field.TypeString, Size: 100}, {Name: "nick", Type: field.TypeString, Size: 100},
{Name: "password", Type: field.TypeString, Nullable: true}, {Name: "password", Type: field.TypeString, Nullable: true},
{Name: "status", Type: field.TypeEnum, Enums: []string{"active", "inactive", "manual_banned", "sys_banned"}, Default: "active"}, {Name: "status", Type: field.TypeEnum, Enums: []string{"active", "inactive", "manual_banned", "sys_banned"}, Default: "active"},
@ -805,7 +806,7 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "users_groups_users", Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[19]}, Columns: []*schema.Column{UsersColumns[20]},
RefColumns: []*schema.Column{GroupsColumns[0]}, RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },

@ -24550,6 +24550,7 @@ type UserMutation struct {
updated_at *time.Time updated_at *time.Time
deleted_at *time.Time deleted_at *time.Time
email *string email *string
phone *string
nick *string nick *string
password *string password *string
status *user.Status status *user.Status
@ -24870,6 +24871,55 @@ func (m *UserMutation) ResetEmail() {
m.email = nil m.email = nil
} }
// SetPhone sets the "phone" field.
func (m *UserMutation) SetPhone(s string) {
m.phone = &s
}
// Phone returns the value of the "phone" field in the mutation.
func (m *UserMutation) Phone() (r string, exists bool) {
v := m.phone
if v == nil {
return
}
return *v, true
}
// OldPhone returns the old "phone" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldPhone(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldPhone is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldPhone requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldPhone: %w", err)
}
return oldValue.Phone, nil
}
// ClearPhone clears the value of the "phone" field.
func (m *UserMutation) ClearPhone() {
m.phone = nil
m.clearedFields[user.FieldPhone] = struct{}{}
}
// PhoneCleared returns if the "phone" field was cleared in this mutation.
func (m *UserMutation) PhoneCleared() bool {
_, ok := m.clearedFields[user.FieldPhone]
return ok
}
// ResetPhone resets all changes to the "phone" field.
func (m *UserMutation) ResetPhone() {
m.phone = nil
delete(m.clearedFields, user.FieldPhone)
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (m *UserMutation) SetNick(s string) { func (m *UserMutation) SetNick(s string) {
m.nick = &s m.nick = &s
@ -26393,7 +26443,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *UserMutation) Fields() []string { func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 19) fields := make([]string, 0, 20)
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt) fields = append(fields, user.FieldCreatedAt)
} }
@ -26406,6 +26456,9 @@ func (m *UserMutation) Fields() []string {
if m.email != nil { if m.email != nil {
fields = append(fields, user.FieldEmail) fields = append(fields, user.FieldEmail)
} }
if m.phone != nil {
fields = append(fields, user.FieldPhone)
}
if m.nick != nil { if m.nick != nil {
fields = append(fields, user.FieldNick) fields = append(fields, user.FieldNick)
} }
@ -26467,6 +26520,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.DeletedAt() return m.DeletedAt()
case user.FieldEmail: case user.FieldEmail:
return m.Email() return m.Email()
case user.FieldPhone:
return m.Phone()
case user.FieldNick: case user.FieldNick:
return m.Nick() return m.Nick()
case user.FieldPassword: case user.FieldPassword:
@ -26514,6 +26569,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldDeletedAt(ctx) return m.OldDeletedAt(ctx)
case user.FieldEmail: case user.FieldEmail:
return m.OldEmail(ctx) return m.OldEmail(ctx)
case user.FieldPhone:
return m.OldPhone(ctx)
case user.FieldNick: case user.FieldNick:
return m.OldNick(ctx) return m.OldNick(ctx)
case user.FieldPassword: case user.FieldPassword:
@ -26581,6 +26638,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
} }
m.SetEmail(v) m.SetEmail(v)
return nil return nil
case user.FieldPhone:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetPhone(v)
return nil
case user.FieldNick: case user.FieldNick:
v, ok := value.(string) v, ok := value.(string)
if !ok { if !ok {
@ -26758,6 +26822,9 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldDeletedAt) { if m.FieldCleared(user.FieldDeletedAt) {
fields = append(fields, user.FieldDeletedAt) fields = append(fields, user.FieldDeletedAt)
} }
if m.FieldCleared(user.FieldPhone) {
fields = append(fields, user.FieldPhone)
}
if m.FieldCleared(user.FieldPassword) { if m.FieldCleared(user.FieldPassword) {
fields = append(fields, user.FieldPassword) fields = append(fields, user.FieldPassword)
} }
@ -26805,6 +26872,9 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldDeletedAt: case user.FieldDeletedAt:
m.ClearDeletedAt() m.ClearDeletedAt()
return nil return nil
case user.FieldPhone:
m.ClearPhone()
return nil
case user.FieldPassword: case user.FieldPassword:
m.ClearPassword() m.ClearPassword()
return nil return nil
@ -26855,6 +26925,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldEmail: case user.FieldEmail:
m.ResetEmail() m.ResetEmail()
return nil return nil
case user.FieldPhone:
m.ResetPhone()
return nil
case user.FieldNick: case user.FieldNick:
m.ResetNick() m.ResetNick()
return nil return nil

@ -677,24 +677,28 @@ func init() {
userDescEmail := userFields[0].Descriptor() userDescEmail := userFields[0].Descriptor()
// user.EmailValidator is a validator for the "email" field. It is called by the builders before save. // user.EmailValidator is a validator for the "email" field. It is called by the builders before save.
user.EmailValidator = userDescEmail.Validators[0].(func(string) error) user.EmailValidator = userDescEmail.Validators[0].(func(string) error)
// userDescPhone is the schema descriptor for phone field.
userDescPhone := userFields[1].Descriptor()
// user.PhoneValidator is a validator for the "phone" field. It is called by the builders before save.
user.PhoneValidator = userDescPhone.Validators[0].(func(string) error)
// userDescNick is the schema descriptor for nick field. // userDescNick is the schema descriptor for nick field.
userDescNick := userFields[1].Descriptor() userDescNick := userFields[2].Descriptor()
// user.NickValidator is a validator for the "nick" field. It is called by the builders before save. // user.NickValidator is a validator for the "nick" field. It is called by the builders before save.
user.NickValidator = userDescNick.Validators[0].(func(string) error) user.NickValidator = userDescNick.Validators[0].(func(string) error)
// userDescStorage is the schema descriptor for storage field. // userDescStorage is the schema descriptor for storage field.
userDescStorage := userFields[7].Descriptor() userDescStorage := userFields[8].Descriptor()
// user.DefaultStorage holds the default value on creation for the storage field. // user.DefaultStorage holds the default value on creation for the storage field.
user.DefaultStorage = userDescStorage.Default.(int64) user.DefaultStorage = userDescStorage.Default.(int64)
// userDescCredits is the schema descriptor for credits field. // userDescCredits is the schema descriptor for credits field.
userDescCredits := userFields[8].Descriptor() userDescCredits := userFields[9].Descriptor()
// user.DefaultCredits holds the default value on creation for the credits field. // user.DefaultCredits holds the default value on creation for the credits field.
user.DefaultCredits = userDescCredits.Default.(int64) user.DefaultCredits = userDescCredits.Default.(int64)
// userDescVaultFolder is the schema descriptor for vault_folder field. // userDescVaultFolder is the schema descriptor for vault_folder field.
userDescVaultFolder := userFields[11].Descriptor() userDescVaultFolder := userFields[12].Descriptor()
// user.DefaultVaultFolder holds the default value on creation for the vault_folder field. // user.DefaultVaultFolder holds the default value on creation for the vault_folder field.
user.DefaultVaultFolder = userDescVaultFolder.Default.(int) user.DefaultVaultFolder = userDescVaultFolder.Default.(int)
// userDescSettings is the schema descriptor for settings field. // userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[14].Descriptor() userDescSettings := userFields[15].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field. // user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting) user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
usergrantMixin := schema.UserGrant{}.Mixin() usergrantMixin := schema.UserGrant{}.Mixin()

@ -17,6 +17,13 @@ func (User) Fields() []ent.Field {
field.String("email"). field.String("email").
MaxLen(100). MaxLen(100).
Unique(), Unique(),
// phone is the mobile number used for SMS sign-in and password
// reset; NULL when the user never bound one.
field.String("phone").
MaxLen(20).
Optional().
Nillable().
Unique(),
field.String("nick"). field.String("nick").
MaxLen(100), MaxLen(100),
field.String("password"). field.String("password").

@ -28,6 +28,8 @@ type User struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Email holds the value of the "email" field. // Email holds the value of the "email" field.
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
// Phone holds the value of the "phone" field.
Phone *string `json:"phone,omitempty"`
// Nick holds the value of the "nick" field. // Nick holds the value of the "nick" field.
Nick string `json:"nick,omitempty"` Nick string `json:"nick,omitempty"`
// Password holds the value of the "password" field. // Password holds the value of the "password" field.
@ -238,7 +240,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte) values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldVaultFolder, user.FieldGroupUsers: case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldVaultFolder, user.FieldGroupUsers:
values[i] = new(sql.NullInt64) values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldVaultPassword, user.FieldAvatar: case user.FieldEmail, user.FieldPhone, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldVaultPassword, user.FieldAvatar:
values[i] = new(sql.NullString) values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires, user.FieldLastLogin: case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires, user.FieldLastLogin:
values[i] = new(sql.NullTime) values[i] = new(sql.NullTime)
@ -288,6 +290,13 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid { } else if value.Valid {
u.Email = value.String u.Email = value.String
} }
case user.FieldPhone:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field phone", values[i])
} else if value.Valid {
u.Phone = new(string)
*u.Phone = value.String
}
case user.FieldNick: case user.FieldNick:
if value, ok := values[i].(*sql.NullString); !ok { if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field nick", values[i]) return fmt.Errorf("unexpected type %T for field nick", values[i])
@ -504,6 +513,11 @@ func (u *User) String() string {
builder.WriteString("email=") builder.WriteString("email=")
builder.WriteString(u.Email) builder.WriteString(u.Email)
builder.WriteString(", ") builder.WriteString(", ")
if v := u.Phone; v != nil {
builder.WriteString("phone=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("nick=") builder.WriteString("nick=")
builder.WriteString(u.Nick) builder.WriteString(u.Nick)
builder.WriteString(", ") builder.WriteString(", ")

@ -25,6 +25,8 @@ const (
FieldDeletedAt = "deleted_at" FieldDeletedAt = "deleted_at"
// FieldEmail holds the string denoting the email field in the database. // FieldEmail holds the string denoting the email field in the database.
FieldEmail = "email" FieldEmail = "email"
// FieldPhone holds the string denoting the phone field in the database.
FieldPhone = "phone"
// FieldNick holds the string denoting the nick field in the database. // FieldNick holds the string denoting the nick field in the database.
FieldNick = "nick" FieldNick = "nick"
// FieldPassword holds the string denoting the password field in the database. // FieldPassword holds the string denoting the password field in the database.
@ -192,6 +194,7 @@ var Columns = []string{
FieldUpdatedAt, FieldUpdatedAt,
FieldDeletedAt, FieldDeletedAt,
FieldEmail, FieldEmail,
FieldPhone,
FieldNick, FieldNick,
FieldPassword, FieldPassword,
FieldStatus, FieldStatus,
@ -235,6 +238,8 @@ var (
UpdateDefaultUpdatedAt func() time.Time UpdateDefaultUpdatedAt func() time.Time
// EmailValidator is a validator for the "email" field. It is called by the builders before save. // EmailValidator is a validator for the "email" field. It is called by the builders before save.
EmailValidator func(string) error EmailValidator func(string) error
// PhoneValidator is a validator for the "phone" field. It is called by the builders before save.
PhoneValidator func(string) error
// NickValidator is a validator for the "nick" field. It is called by the builders before save. // NickValidator is a validator for the "nick" field. It is called by the builders before save.
NickValidator func(string) error NickValidator func(string) error
// DefaultStorage holds the default value on creation for the "storage" field. // DefaultStorage holds the default value on creation for the "storage" field.
@ -303,6 +308,11 @@ func ByEmail(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldEmail, opts...).ToFunc() return sql.OrderByField(FieldEmail, opts...).ToFunc()
} }
// ByPhone orders the results by the phone field.
func ByPhone(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPhone, opts...).ToFunc()
}
// ByNick orders the results by the nick field. // ByNick orders the results by the nick field.
func ByNick(opts ...sql.OrderTermOption) OrderOption { func ByNick(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldNick, opts...).ToFunc() return sql.OrderByField(FieldNick, opts...).ToFunc()

@ -75,6 +75,11 @@ func Email(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldEmail, v)) return predicate.User(sql.FieldEQ(FieldEmail, v))
} }
// Phone applies equality check predicate on the "phone" field. It's identical to PhoneEQ.
func Phone(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldPhone, v))
}
// Nick applies equality check predicate on the "nick" field. It's identical to NickEQ. // Nick applies equality check predicate on the "nick" field. It's identical to NickEQ.
func Nick(v string) predicate.User { func Nick(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldNick, v)) return predicate.User(sql.FieldEQ(FieldNick, v))
@ -330,6 +335,81 @@ func EmailContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldEmail, v)) return predicate.User(sql.FieldContainsFold(FieldEmail, v))
} }
// PhoneEQ applies the EQ predicate on the "phone" field.
func PhoneEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldPhone, v))
}
// PhoneNEQ applies the NEQ predicate on the "phone" field.
func PhoneNEQ(v string) predicate.User {
return predicate.User(sql.FieldNEQ(FieldPhone, v))
}
// PhoneIn applies the In predicate on the "phone" field.
func PhoneIn(vs ...string) predicate.User {
return predicate.User(sql.FieldIn(FieldPhone, vs...))
}
// PhoneNotIn applies the NotIn predicate on the "phone" field.
func PhoneNotIn(vs ...string) predicate.User {
return predicate.User(sql.FieldNotIn(FieldPhone, vs...))
}
// PhoneGT applies the GT predicate on the "phone" field.
func PhoneGT(v string) predicate.User {
return predicate.User(sql.FieldGT(FieldPhone, v))
}
// PhoneGTE applies the GTE predicate on the "phone" field.
func PhoneGTE(v string) predicate.User {
return predicate.User(sql.FieldGTE(FieldPhone, v))
}
// PhoneLT applies the LT predicate on the "phone" field.
func PhoneLT(v string) predicate.User {
return predicate.User(sql.FieldLT(FieldPhone, v))
}
// PhoneLTE applies the LTE predicate on the "phone" field.
func PhoneLTE(v string) predicate.User {
return predicate.User(sql.FieldLTE(FieldPhone, v))
}
// PhoneContains applies the Contains predicate on the "phone" field.
func PhoneContains(v string) predicate.User {
return predicate.User(sql.FieldContains(FieldPhone, v))
}
// PhoneHasPrefix applies the HasPrefix predicate on the "phone" field.
func PhoneHasPrefix(v string) predicate.User {
return predicate.User(sql.FieldHasPrefix(FieldPhone, v))
}
// PhoneHasSuffix applies the HasSuffix predicate on the "phone" field.
func PhoneHasSuffix(v string) predicate.User {
return predicate.User(sql.FieldHasSuffix(FieldPhone, v))
}
// PhoneIsNil applies the IsNil predicate on the "phone" field.
func PhoneIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldPhone))
}
// PhoneNotNil applies the NotNil predicate on the "phone" field.
func PhoneNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldPhone))
}
// PhoneEqualFold applies the EqualFold predicate on the "phone" field.
func PhoneEqualFold(v string) predicate.User {
return predicate.User(sql.FieldEqualFold(FieldPhone, v))
}
// PhoneContainsFold applies the ContainsFold predicate on the "phone" field.
func PhoneContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldPhone, v))
}
// NickEQ applies the EQ predicate on the "nick" field. // NickEQ applies the EQ predicate on the "nick" field.
func NickEQ(v string) predicate.User { func NickEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldNick, v)) return predicate.User(sql.FieldEQ(FieldNick, v))

@ -85,6 +85,20 @@ func (uc *UserCreate) SetEmail(s string) *UserCreate {
return uc return uc
} }
// SetPhone sets the "phone" field.
func (uc *UserCreate) SetPhone(s string) *UserCreate {
uc.mutation.SetPhone(s)
return uc
}
// SetNillablePhone sets the "phone" field if the given value is not nil.
func (uc *UserCreate) SetNillablePhone(s *string) *UserCreate {
if s != nil {
uc.SetPhone(*s)
}
return uc
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (uc *UserCreate) SetNick(s string) *UserCreate { func (uc *UserCreate) SetNick(s string) *UserCreate {
uc.mutation.SetNick(s) uc.mutation.SetNick(s)
@ -559,6 +573,11 @@ func (uc *UserCreate) check() error {
return &ValidationError{Name: "email", err: fmt.Errorf(`ent: validator failed for field "User.email": %w`, err)} return &ValidationError{Name: "email", err: fmt.Errorf(`ent: validator failed for field "User.email": %w`, err)}
} }
} }
if v, ok := uc.mutation.Phone(); ok {
if err := user.PhoneValidator(v); err != nil {
return &ValidationError{Name: "phone", err: fmt.Errorf(`ent: validator failed for field "User.phone": %w`, err)}
}
}
if _, ok := uc.mutation.Nick(); !ok { if _, ok := uc.mutation.Nick(); !ok {
return &ValidationError{Name: "nick", err: errors.New(`ent: missing required field "User.nick"`)} return &ValidationError{Name: "nick", err: errors.New(`ent: missing required field "User.nick"`)}
} }
@ -637,6 +656,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldEmail, field.TypeString, value) _spec.SetField(user.FieldEmail, field.TypeString, value)
_node.Email = value _node.Email = value
} }
if value, ok := uc.mutation.Phone(); ok {
_spec.SetField(user.FieldPhone, field.TypeString, value)
_node.Phone = &value
}
if value, ok := uc.mutation.Nick(); ok { if value, ok := uc.mutation.Nick(); ok {
_spec.SetField(user.FieldNick, field.TypeString, value) _spec.SetField(user.FieldNick, field.TypeString, value)
_node.Nick = value _node.Nick = value
@ -1012,6 +1035,24 @@ func (u *UserUpsert) UpdateEmail() *UserUpsert {
return u return u
} }
// SetPhone sets the "phone" field.
func (u *UserUpsert) SetPhone(v string) *UserUpsert {
u.Set(user.FieldPhone, v)
return u
}
// UpdatePhone sets the "phone" field to the value that was provided on create.
func (u *UserUpsert) UpdatePhone() *UserUpsert {
u.SetExcluded(user.FieldPhone)
return u
}
// ClearPhone clears the value of the "phone" field.
func (u *UserUpsert) ClearPhone() *UserUpsert {
u.SetNull(user.FieldPhone)
return u
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (u *UserUpsert) SetNick(v string) *UserUpsert { func (u *UserUpsert) SetNick(v string) *UserUpsert {
u.Set(user.FieldNick, v) u.Set(user.FieldNick, v)
@ -1364,6 +1405,27 @@ func (u *UserUpsertOne) UpdateEmail() *UserUpsertOne {
}) })
} }
// SetPhone sets the "phone" field.
func (u *UserUpsertOne) SetPhone(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetPhone(v)
})
}
// UpdatePhone sets the "phone" field to the value that was provided on create.
func (u *UserUpsertOne) UpdatePhone() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdatePhone()
})
}
// ClearPhone clears the value of the "phone" field.
func (u *UserUpsertOne) ClearPhone() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearPhone()
})
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (u *UserUpsertOne) SetNick(v string) *UserUpsertOne { func (u *UserUpsertOne) SetNick(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) { return u.Update(func(s *UserUpsert) {
@ -1930,6 +1992,27 @@ func (u *UserUpsertBulk) UpdateEmail() *UserUpsertBulk {
}) })
} }
// SetPhone sets the "phone" field.
func (u *UserUpsertBulk) SetPhone(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetPhone(v)
})
}
// UpdatePhone sets the "phone" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdatePhone() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdatePhone()
})
}
// ClearPhone clears the value of the "phone" field.
func (u *UserUpsertBulk) ClearPhone() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearPhone()
})
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (u *UserUpsertBulk) SetNick(v string) *UserUpsertBulk { func (u *UserUpsertBulk) SetNick(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) { return u.Update(func(s *UserUpsert) {

@ -84,6 +84,26 @@ func (uu *UserUpdate) SetNillableEmail(s *string) *UserUpdate {
return uu return uu
} }
// SetPhone sets the "phone" field.
func (uu *UserUpdate) SetPhone(s string) *UserUpdate {
uu.mutation.SetPhone(s)
return uu
}
// SetNillablePhone sets the "phone" field if the given value is not nil.
func (uu *UserUpdate) SetNillablePhone(s *string) *UserUpdate {
if s != nil {
uu.SetPhone(*s)
}
return uu
}
// ClearPhone clears the value of the "phone" field.
func (uu *UserUpdate) ClearPhone() *UserUpdate {
uu.mutation.ClearPhone()
return uu
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (uu *UserUpdate) SetNick(s string) *UserUpdate { func (uu *UserUpdate) SetNick(s string) *UserUpdate {
uu.mutation.SetNick(s) uu.mutation.SetNick(s)
@ -904,6 +924,11 @@ func (uu *UserUpdate) check() error {
return &ValidationError{Name: "email", err: fmt.Errorf(`ent: validator failed for field "User.email": %w`, err)} return &ValidationError{Name: "email", err: fmt.Errorf(`ent: validator failed for field "User.email": %w`, err)}
} }
} }
if v, ok := uu.mutation.Phone(); ok {
if err := user.PhoneValidator(v); err != nil {
return &ValidationError{Name: "phone", err: fmt.Errorf(`ent: validator failed for field "User.phone": %w`, err)}
}
}
if v, ok := uu.mutation.Nick(); ok { if v, ok := uu.mutation.Nick(); ok {
if err := user.NickValidator(v); err != nil { if err := user.NickValidator(v); err != nil {
return &ValidationError{Name: "nick", err: fmt.Errorf(`ent: validator failed for field "User.nick": %w`, err)} return &ValidationError{Name: "nick", err: fmt.Errorf(`ent: validator failed for field "User.nick": %w`, err)}
@ -944,6 +969,12 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := uu.mutation.Email(); ok { if value, ok := uu.mutation.Email(); ok {
_spec.SetField(user.FieldEmail, field.TypeString, value) _spec.SetField(user.FieldEmail, field.TypeString, value)
} }
if value, ok := uu.mutation.Phone(); ok {
_spec.SetField(user.FieldPhone, field.TypeString, value)
}
if uu.mutation.PhoneCleared() {
_spec.ClearField(user.FieldPhone, field.TypeString)
}
if value, ok := uu.mutation.Nick(); ok { if value, ok := uu.mutation.Nick(); ok {
_spec.SetField(user.FieldNick, field.TypeString, value) _spec.SetField(user.FieldNick, field.TypeString, value)
} }
@ -1704,6 +1735,26 @@ func (uuo *UserUpdateOne) SetNillableEmail(s *string) *UserUpdateOne {
return uuo return uuo
} }
// SetPhone sets the "phone" field.
func (uuo *UserUpdateOne) SetPhone(s string) *UserUpdateOne {
uuo.mutation.SetPhone(s)
return uuo
}
// SetNillablePhone sets the "phone" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillablePhone(s *string) *UserUpdateOne {
if s != nil {
uuo.SetPhone(*s)
}
return uuo
}
// ClearPhone clears the value of the "phone" field.
func (uuo *UserUpdateOne) ClearPhone() *UserUpdateOne {
uuo.mutation.ClearPhone()
return uuo
}
// SetNick sets the "nick" field. // SetNick sets the "nick" field.
func (uuo *UserUpdateOne) SetNick(s string) *UserUpdateOne { func (uuo *UserUpdateOne) SetNick(s string) *UserUpdateOne {
uuo.mutation.SetNick(s) uuo.mutation.SetNick(s)
@ -2537,6 +2588,11 @@ func (uuo *UserUpdateOne) check() error {
return &ValidationError{Name: "email", err: fmt.Errorf(`ent: validator failed for field "User.email": %w`, err)} return &ValidationError{Name: "email", err: fmt.Errorf(`ent: validator failed for field "User.email": %w`, err)}
} }
} }
if v, ok := uuo.mutation.Phone(); ok {
if err := user.PhoneValidator(v); err != nil {
return &ValidationError{Name: "phone", err: fmt.Errorf(`ent: validator failed for field "User.phone": %w`, err)}
}
}
if v, ok := uuo.mutation.Nick(); ok { if v, ok := uuo.mutation.Nick(); ok {
if err := user.NickValidator(v); err != nil { if err := user.NickValidator(v); err != nil {
return &ValidationError{Name: "nick", err: fmt.Errorf(`ent: validator failed for field "User.nick": %w`, err)} return &ValidationError{Name: "nick", err: fmt.Errorf(`ent: validator failed for field "User.nick": %w`, err)}
@ -2594,6 +2650,12 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if value, ok := uuo.mutation.Email(); ok { if value, ok := uuo.mutation.Email(); ok {
_spec.SetField(user.FieldEmail, field.TypeString, value) _spec.SetField(user.FieldEmail, field.TypeString, value)
} }
if value, ok := uuo.mutation.Phone(); ok {
_spec.SetField(user.FieldPhone, field.TypeString, value)
}
if uuo.mutation.PhoneCleared() {
_spec.ClearField(user.FieldPhone, field.TypeString)
}
if value, ok := uuo.mutation.Nick(); ok { if value, ok := uuo.mutation.Nick(); ok {
_spec.SetField(user.FieldNick, field.TypeString, value) _spec.SetField(user.FieldNick, field.TypeString, value)
} }

@ -38,6 +38,12 @@
"backToSingIn": "Back to sign in", "backToSingIn": "Back to sign in",
"sendMeAnEmail": "Send me an email", "sendMeAnEmail": "Send me an email",
"resetEmailSent": "An email has been sent, please pay attention to check.", "resetEmailSent": "An email has been sent, please pay attention to check.",
"phoneNumber": "Phone number",
"smsCode": "SMS code",
"sendSmsCode": "Send code",
"resendSmsCode": "Resend ({{seconds}}s)",
"smsSignIn": "Sign in with SMS",
"resetViaSms": "Reset via SMS instead",
"browserNotSupport": "Not supported by current browser or environment.", "browserNotSupport": "Not supported by current browser or environment.",
"success": "Sign in successful", "success": "Sign in successful",
"signUpSuccess": "Sign up successful", "signUpSuccess": "Sign up successful",
@ -941,6 +947,10 @@
"linkQQAccount": "Link QQ account", "linkQQAccount": "Link QQ account",
"providerWeChat": "WeChat", "providerWeChat": "WeChat",
"linkWeChatAccount": "Link WeChat account", "linkWeChatAccount": "Link WeChat account",
"phoneBinding": "Phone binding",
"bindPhone": "Bind phone",
"unbindPhone": "Unbind",
"unbindPhoneConfirm": "Are you sure you want to unbind this phone number? You will no longer be able to sign in or reset your password with it.",
"nickNameDes": "This is your public display name. It can be your real name or a pseudonym.", "nickNameDes": "This is your public display name. It can be your real name or a pseudonym.",
"changeEmail": "Change", "changeEmail": "Change",
"changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.", "changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.",

@ -885,6 +885,16 @@
"wechatCallbackUrlDes": "Register this URL's domain as the authorization callback domain in the WeChat Open Platform console: <0>{{url}}</0>", "wechatCallbackUrlDes": "Register this URL's domain as the authorization callback domain in the WeChat Open Platform console: <0>{{url}}</0>",
"wechatRegisterEnabled": "Allow automatic registration", "wechatRegisterEnabled": "Allow automatic registration",
"wechatRegisterEnabledDes": "Automatically create a local account when a user signs in via WeChat for the first time. WeChat supplies no email address, so provisioned accounts use a synthetic @connect.wechat.local address.", "wechatRegisterEnabledDes": "Automatically create a local account when a user signs in via WeChat for the first time. WeChat supplies no email address, so provisioned accounts use a synthetic @connect.wechat.local address.",
"smsSignIn": "SMS sign-in",
"smsEndpoint": "Gateway endpoint",
"smsEndpointDes": "HTTP endpoint of your SMS provider. The placeholders <0>{phone}</0> and <0>{code}</0> are replaced before each request.",
"smsMethodDes": "HTTP method used for the gateway request. For GET, placeholders are rendered into the endpoint URL only.",
"smsHeaders": "Request headers",
"smsHeadersDes": "One `Key: Value` pair per line, e.g. Authorization: Bearer <token>.",
"smsBodyTemplate": "Request body template",
"smsBodyTemplateDes": "Body sent for POST requests. The placeholders <0>{phone}</0> and <0>{code}</0> are replaced before each request.",
"smsRegisterEnabled": "Allow automatic registration",
"smsRegisterEnabledDes": "Automatically create a local account when a user signs in via SMS code for the first time. Provisioned accounts use a synthetic @sms.local address.",
"themeVisible": "Visible", "themeVisible": "Visible",
"shareDefaultPrivate": "Private share by default", "shareDefaultPrivate": "Private share by default",
"shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.", "shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.",

@ -38,6 +38,12 @@
"backToSingIn": "返回登录", "backToSingIn": "返回登录",
"sendMeAnEmail": "发送密码重置邮件", "sendMeAnEmail": "发送密码重置邮件",
"resetEmailSent": "密码重置邮件已发送,请注意查收", "resetEmailSent": "密码重置邮件已发送,请注意查收",
"phoneNumber": "手机号码",
"smsCode": "短信验证码",
"sendSmsCode": "发送验证码",
"resendSmsCode": "重新发送 ({{seconds}}秒)",
"smsSignIn": "短信验证码登录",
"resetViaSms": "通过短信验证码重置",
"browserNotSupport": "当前浏览器或环境不支持", "browserNotSupport": "当前浏览器或环境不支持",
"success": "登录成功", "success": "登录成功",
"signUpSuccess": "注册成功", "signUpSuccess": "注册成功",
@ -941,6 +947,10 @@
"linkQQAccount": "绑定 QQ 账号", "linkQQAccount": "绑定 QQ 账号",
"providerWeChat": "微信", "providerWeChat": "微信",
"linkWeChatAccount": "绑定微信账号", "linkWeChatAccount": "绑定微信账号",
"phoneBinding": "手机绑定",
"bindPhone": "绑定手机",
"unbindPhone": "解绑",
"unbindPhoneConfirm": "确定要解绑此手机号吗?解绑后将无法再使用该号码登录或重置密码。",
"nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称", "nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称",
"changeEmail": "更换", "changeEmail": "更换",
"changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。", "changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。",

@ -885,6 +885,16 @@
"wechatCallbackUrlDes": "请在微信开放平台控制台中将此 URL 的域名注册为授权回调域:<0>{{url}}</0>", "wechatCallbackUrlDes": "请在微信开放平台控制台中将此 URL 的域名注册为授权回调域:<0>{{url}}</0>",
"wechatRegisterEnabled": "允许自动注册", "wechatRegisterEnabled": "允许自动注册",
"wechatRegisterEnabledDes": "用户首次通过微信登录时自动创建本地账号。微信不提供邮箱,自动注册的账号使用 @connect.wechat.local 合成邮箱。", "wechatRegisterEnabledDes": "用户首次通过微信登录时自动创建本地账号。微信不提供邮箱,自动注册的账号使用 @connect.wechat.local 合成邮箱。",
"smsSignIn": "短信登录",
"smsEndpoint": "网关地址",
"smsEndpointDes": "短信服务提供商的 HTTP 接口地址。每次请求前会替换占位符 <0>{phone}</0> 和 <0>{code}</0>。",
"smsMethodDes": "网关请求使用的 HTTP 方法。GET 方式下占位符仅会渲染到接口地址中。",
"smsHeaders": "请求头",
"smsHeadersDes": "每行一个 `Key: Value`,例如 Authorization: Bearer <token>。",
"smsBodyTemplate": "请求体模板",
"smsBodyTemplateDes": "POST 请求发送的请求体。每次请求前会替换占位符 <0>{phone}</0> 和 <0>{code}</0>。",
"smsRegisterEnabled": "允许自动注册",
"smsRegisterEnabledDes": "用户首次通过短信验证码登录时自动创建本地账号。自动注册的账号使用 @sms.local 合成邮箱。",
"themeVisible": "可见", "themeVisible": "可见",
"shareDefaultPrivate": "默认私密分享", "shareDefaultPrivate": "默认私密分享",
"shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。", "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。",

@ -117,6 +117,10 @@ import {
SendResetEmailService, SendResetEmailService,
ShopSku, ShopSku,
SignUpService, SignUpService,
SmsBindRequest,
SmsLoginRequest,
SmsResetRequest,
SmsSendCodeRequest,
Token, Token,
TwoFALoginRequest, TwoFALoginRequest,
User, User,
@ -1727,6 +1731,91 @@ export function sendResetEmail(req: SendResetEmailService): ThunkResponse<User>
}; };
} }
export function sendSmsCode(req: SmsSendCodeRequest): ThunkResponse<null> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/session/sms/send",
{
method: "POST",
data: req,
},
{
...defaultOpts,
noCredential: true,
},
),
);
};
}
export function sendSmsLogin(req: SmsLoginRequest): ThunkResponse<LoginResponse> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/session/sms/login",
{
method: "POST",
data: req,
},
{
...defaultOpts,
noCredential: true,
bypassSnackbar: (e) => e instanceof AppError && e.code == Code.Continue,
},
),
);
};
}
export function sendSmsReset(req: SmsResetRequest): ThunkResponse<User> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/reset_sms",
{
method: "POST",
data: req,
},
{
...defaultOpts,
noCredential: true,
},
),
);
};
}
export function bindPhone(req: SmsBindRequest): ThunkResponse<null> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/setting/phone",
{
method: "PUT",
data: req,
},
defaultOpts,
),
);
};
}
export function unbindPhone(): ThunkResponse<null> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/setting/phone",
{
method: "DELETE",
data: {},
},
defaultOpts,
),
);
};
}
export function sendReset(uid: string, req: ResetPasswordService): ThunkResponse<User> { export function sendReset(uid: string, req: ResetPasswordService): ThunkResponse<User> {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
return await dispatch( return await dispatch(

@ -34,6 +34,7 @@ export interface SiteConfig {
sso_auto_redirect?: boolean; sso_auto_redirect?: boolean;
qq_connect_enabled?: boolean; qq_connect_enabled?: boolean;
wechat_connect_enabled?: boolean; wechat_connect_enabled?: boolean;
sms_enabled?: boolean;
download_cdn_routes?: { name: string; url: string }[]; download_cdn_routes?: { name: string; url: string }[];
download_cdn_shuffle?: boolean; download_cdn_shuffle?: boolean;
abuse_captcha?: boolean; abuse_captcha?: boolean;

@ -155,6 +155,8 @@ export interface UserSettings {
linked_accounts?: LinkedAccount[]; linked_accounts?: LinkedAccount[];
vault_enabled: boolean; vault_enabled: boolean;
vault_unlocked: boolean; vault_unlocked: boolean;
// Bound mobile number, masked for display (e.g. "+86****34").
phone?: string;
} }
export interface LinkedAccount { export interface LinkedAccount {
@ -269,6 +271,29 @@ export interface ResetPasswordService {
secret: string; secret: string;
} }
export type SmsScene = "login" | "bind" | "reset";
export interface SmsSendCodeRequest extends CaptchaRequest {
phone: string;
scene: SmsScene;
}
export interface SmsLoginRequest {
phone: string;
code: string;
}
export interface SmsResetRequest {
phone: string;
code: string;
password: string;
}
export interface SmsBindRequest {
phone: string;
code: string;
}
export enum ShareLinksInProfileLevel { export enum ShareLinksInProfileLevel {
site_default = "", site_default = "",
public_share_only = "public_share", public_share_only = "public_share",

@ -0,0 +1,113 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { AccordionDetails, FormControl, FormControlLabel, ListItemText, Switch, Typography } from "@mui/material";
import { useContext } from "react";
import { Trans, useTranslation } from "react-i18next";
import { isTrueVal } from "../../../../session/utils.ts";
import { Code } from "../../../Common/Code.tsx";
import { DenseFilledTextField, DenseSelect } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import { NoMarginHelperText, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import { AccordionSummary, StyledAccordion } from "./SSOSettings.tsx";
const SmsGatewaySettings = () => {
const { t } = useTranslation("dashboard");
const { setSettings, values } = useContext(SettingContext);
const enabled = isTrueVal(values.sms_enabled);
return (
<StyledAccordion disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel
control={
<Switch
size="small"
checked={enabled}
onChange={(e) =>
setSettings({
sms_enabled: e.target.checked ? "1" : "0",
})
}
onClick={(e) => e.stopPropagation()}
/>
}
label={t("settings.smsSignIn")}
/>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<SettingSectionContent>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.smsEndpoint")}
value={values.sms_endpoint ?? ""}
onChange={(e) => setSettings({ sms_endpoint: e.target.value })}
required={enabled}
placeholder="https://sms.example.com/send?phone={phone}&code={code}"
/>
<NoMarginHelperText>
<Trans i18nKey="settings.smsEndpointDes" ns="dashboard" components={[<Code key="0" />]} />
</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseSelect
value={values.sms_method ?? "POST"}
onChange={(e) => setSettings({ sms_method: e.target.value as string })}
>
{["POST", "GET"].map((m) => (
<SquareMenuItem key={m} value={m}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>{m}</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("settings.smsMethodDes")}</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.smsHeaders")}
value={values.sms_headers ?? ""}
onChange={(e) => setSettings({ sms_headers: e.target.value })}
multiline
minRows={2}
placeholder={"Authorization: Bearer <token>"}
/>
<NoMarginHelperText>{t("settings.smsHeadersDes")}</NoMarginHelperText>
</FormControl>
{(values.sms_method ?? "POST") !== "GET" && (
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.smsBodyTemplate")}
value={values.sms_body_tpl ?? ""}
onChange={(e) => setSettings({ sms_body_tpl: e.target.value })}
multiline
minRows={2}
placeholder={'{"phone":"{phone}","code":"{code}"}'}
/>
<NoMarginHelperText>
<Trans i18nKey="settings.smsBodyTemplateDes" ns="dashboard" components={[<Code key="0" />]} />
</NoMarginHelperText>
</FormControl>
)}
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.sms_register_enabled ?? "1")}
onChange={(e) =>
setSettings({
sms_register_enabled: e.target.checked ? "1" : "0",
})
}
/>
}
label={<Typography variant="body2">{t("settings.smsRegisterEnabled")}</Typography>}
/>
<NoMarginHelperText>{t("settings.smsRegisterEnabledDes")}</NoMarginHelperText>
</FormControl>
</SettingSectionContent>
</AccordionDetails>
</StyledAccordion>
);
};
export default SmsGatewaySettings;

@ -13,6 +13,7 @@ import SharesInput from "../../Common/SharesInput.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx"; import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx"; import { SettingContext } from "../SettingWrapper.tsx";
import QQConnectSettings from "./QQConnectSettings.tsx"; import QQConnectSettings from "./QQConnectSettings.tsx";
import SmsGatewaySettings from "./SmsGatewaySettings.tsx";
import SSOSettings from "./SSOSettings.tsx"; import SSOSettings from "./SSOSettings.tsx";
import WeChatConnectSettings from "./WeChatConnectSettings.tsx"; import WeChatConnectSettings from "./WeChatConnectSettings.tsx";
@ -311,6 +312,9 @@ const UserSession = () => {
<SettingForm lgWidth={5}> <SettingForm lgWidth={5}>
<WeChatConnectSettings /> <WeChatConnectSettings />
</SettingForm> </SettingForm>
<SettingForm lgWidth={5}>
<SmsGatewaySettings />
</SettingForm>
</SettingSectionContent> </SettingSectionContent>
</SettingSection> </SettingSection>
<SettingSection> <SettingSection>

@ -1,4 +1,4 @@
import { Box, Divider, FormControl, Link, Stack } from "@mui/material"; import { Box, Button, Divider, FormControl, Link, Stack } from "@mui/material";
import { useEffect } from "react"; import { useEffect } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
@ -7,6 +7,7 @@ import { useAppSelector } from "../../../../redux/hooks.ts";
import { useQuery } from "../../../../util"; import { useQuery } from "../../../../util";
import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx"; import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx";
import MailOutlined from "../../../Icons/MailOutlined.tsx"; import MailOutlined from "../../../Icons/MailOutlined.tsx";
import PhoneLaptopOutlined from "../../../Icons/PhoneLaptopOutlined.tsx";
import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx"; import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx";
import QQLoginButton from "../Signin/QQLoginButton.tsx"; import QQLoginButton from "../Signin/QQLoginButton.tsx";
import SSOLoginButton from "../Signin/SSOLoginButton.tsx"; import SSOLoginButton from "../Signin/SSOLoginButton.tsx";
@ -50,18 +51,19 @@ interface PhaseCollectEmailProps {
setEmail: (email: string) => void; setEmail: (email: string) => void;
control?: Control; control?: Control;
onOAuthPasskeyLogin?: (response: LoginResponse) => void; onOAuthPasskeyLogin?: (response: LoginResponse) => void;
onSmsLogin?: () => void;
} }
const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: PhaseCollectEmailProps) => { const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin, onSmsLogin }: PhaseCollectEmailProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const query = useQuery(); const query = useQuery();
const { register_enabled, authn, sso_enabled, qq_connect_enabled } = useAppSelector( const { register_enabled, authn, sso_enabled, qq_connect_enabled, sms_enabled } = useAppSelector(
(state) => state.siteConfig.login.config, (state) => state.siteConfig.login.config,
); );
const tos = useAppSelector((state) => state.siteConfig.login.config.tos_url); const tos = useAppSelector((state) => state.siteConfig.login.config.tos_url);
const privacyPolicy = useAppSelector((state) => state.siteConfig.login.config.privacy_policy_url); const privacyPolicy = useAppSelector((state) => state.siteConfig.login.config.privacy_policy_url);
const showFooter = tos || privacyPolicy || authn || sso_enabled || qq_connect_enabled; const showFooter = tos || privacyPolicy || authn || sso_enabled || qq_connect_enabled || sms_enabled;
useEffect(() => { useEffect(() => {
if (!!query.get("email")) { if (!!query.get("email")) {
@ -107,6 +109,11 @@ const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: Ph
<SSOLoginButton /> <SSOLoginButton />
<QQLoginButton /> <QQLoginButton />
<WeChatLoginButton /> <WeChatLoginButton />
{sms_enabled && onSmsLogin && (
<Button variant="outlined" startIcon={<PhoneLaptopOutlined />} onClick={onSmsLogin}>
{t("login.smsSignIn")}
</Button>
)}
</Stack> </Stack>
<LegalLinks /> <LegalLinks />
</> </>

@ -1,19 +1,141 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Control } from "../Signin/SignIn.tsx"; import { Control } from "../Signin/SignIn.tsx";
import { useAppSelector } from "../../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { FormControl } from "@mui/material"; import { Box, Button, FormControl, Link } from "@mui/material";
import { LoadingButton } from "@mui/lab";
import { useEffect, useState } from "react";
import { enqueueSnackbar } from "notistack";
import { sendSmsCode, sendSmsReset } from "../../../../api/api.ts";
import { Captcha, CaptchaParams } from "../../../Common/Captcha/Captcha.tsx"; import { Captcha, CaptchaParams } from "../../../Common/Captcha/Captcha.tsx";
import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx";
import LockClosedOutlined from "../../../Icons/LockClosedOutlined.tsx";
import Password from "../../../Icons/Password.tsx";
import PhoneLaptopOutlined from "../../../Icons/PhoneLaptopOutlined.tsx";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
interface PhaseForgetPasswordProps { interface PhaseForgetPasswordProps {
email: string; email: string;
control?: Control; control?: Control;
captchaGen: number; captchaGen: number;
setCaptchaState: (state: CaptchaParams) => void; setCaptchaState: (state: CaptchaParams) => void;
captchaState?: React.MutableRefObject<CaptchaParams | undefined>;
onSmsResetDone?: () => void;
} }
const PhaseForgetPassword = ({ captchaGen, setCaptchaState, control }: PhaseForgetPasswordProps) => { const RESEND_SECONDS = 60;
const PhaseForgetPassword = ({
captchaGen,
setCaptchaState,
captchaState,
control,
onSmsResetDone,
}: PhaseForgetPasswordProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { forget_captcha } = useAppSelector((state) => state.siteConfig.login.config); const dispatch = useAppDispatch();
const { forget_captcha, sms_enabled, login_captcha } = useAppSelector((state) => state.siteConfig.login.config);
const [smsMode, setSmsMode] = useState(false);
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [sending, setSending] = useState(false);
const [resetting, setResetting] = useState(false);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const onSendCode = () => {
setSending(true);
dispatch(sendSmsCode({ phone, scene: "reset", ...captchaState?.current }))
.then(() => setCountdown(RESEND_SECONDS))
.finally(() => setSending(false));
};
const onSmsReset = () => {
setResetting(true);
dispatch(sendSmsReset({ phone, code, password }))
.then(() => {
enqueueSnackbar({
message: t("login.passwordReset"),
variant: "success",
action: DefaultCloseAction,
});
onSmsResetDone?.();
})
.finally(() => setResetting(false));
};
if (smsMode) {
return (
<>
<FormControl variant="standard" margin="normal" required fullWidth>
<OutlineIconTextField
label={t("login.phoneNumber")}
variant="outlined"
inputProps={{ type: "tel", name: "phone", required: "true" }}
onChange={(e) => setPhone(e.target.value)}
icon={<PhoneLaptopOutlined />}
value={phone}
autoComplete="tel"
autoFocus
/>
</FormControl>
{login_captcha && (
<FormControl variant="standard" margin="normal" required fullWidth>
<Captcha generation={captchaGen} required={true} fullWidth={true} onStateChange={setCaptchaState} />
</FormControl>
)}
<FormControl variant="standard" margin="normal" required fullWidth>
<OutlineIconTextField
label={t("login.smsCode")}
variant="outlined"
inputProps={{ type: "text", required: "true" }}
onChange={(e) => setCode(e.target.value)}
icon={<Password />}
value={code}
/>
</FormControl>
<Button
variant="text"
size="small"
disabled={sending || countdown > 0 || !phone || (login_captcha && !captchaState?.current)}
onClick={onSendCode}
sx={{ mt: 1 }}
>
{countdown > 0 ? t("login.resendSmsCode", { seconds: countdown }) : t("login.sendSmsCode")}
</Button>
<FormControl variant="standard" margin="normal" required fullWidth>
<OutlineIconTextField
label={t("login.newPassword")}
variant="outlined"
inputProps={{ type: "password", required: "true" }}
onChange={(e) => setPassword(e.target.value)}
icon={<LockClosedOutlined />}
value={password}
autoComplete="new-password"
/>
</FormControl>
<LoadingButton
sx={{ mt: 2 }}
fullWidth
variant="contained"
color="primary"
loading={resetting}
disabled={!phone || code.length !== 6 || password.length < 6}
onClick={onSmsReset}
>
<span>{t("login.resetPassword")}</span>
</LoadingButton>
{control?.back}
</>
);
}
return ( return (
<> <>
@ -23,6 +145,13 @@ const PhaseForgetPassword = ({ captchaGen, setCaptchaState, control }: PhaseForg
</FormControl> </FormControl>
)} )}
{control?.submit} {control?.submit}
{sms_enabled && (
<Box sx={{ mt: 1, textAlign: "center", typography: "body2" }}>
<Link component="button" type="button" underline="hover" onClick={() => setSmsMode(true)}>
{t("login.resetViaSms")}
</Link>
</Box>
)}
{control?.back} {control?.back}
</> </>
); );

@ -0,0 +1,112 @@
import { Button, FormControl } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { sendSmsCode } from "../../../../api/api.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { Captcha, CaptchaParams } from "../../../Common/Captcha/Captcha.tsx";
import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx";
import Password from "../../../Icons/Password.tsx";
import PhoneLaptopOutlined from "../../../Icons/PhoneLaptopOutlined.tsx";
import { Control } from "../Signin/SignIn.tsx";
export interface SmsLoginState {
phone: string;
code: string;
}
interface PhaseSmsLoginProps {
setSmsState: (state: SmsLoginState) => void;
control?: Control;
captchaGen: number;
setCaptchaState: (state: CaptchaParams) => void;
captchaState: React.MutableRefObject<CaptchaParams | undefined>;
scene?: "login" | "bind" | "reset";
}
const RESEND_SECONDS = 60;
const PhaseSmsLogin = ({
setSmsState,
control,
captchaGen,
setCaptchaState,
captchaState,
scene = "login",
}: PhaseSmsLoginProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { login_captcha } = useAppSelector((state) => state.siteConfig.login.config);
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const onPhoneChange = (v: string) => {
setPhone(v);
setSmsState({ phone: v, code });
};
const onCodeChange = (v: string) => {
setCode(v);
setSmsState({ phone, code: v });
};
const onSend = () => {
setSending(true);
dispatch(sendSmsCode({ phone, scene, ...captchaState.current }))
.then(() => setCountdown(RESEND_SECONDS))
.finally(() => setSending(false));
};
return (
<>
<FormControl variant="standard" margin="normal" required fullWidth>
<OutlineIconTextField
label={t("login.phoneNumber")}
variant="outlined"
inputProps={{ id: "phone", type: "tel", name: "phone", required: "true" }}
onChange={(e) => onPhoneChange(e.target.value)}
icon={<PhoneLaptopOutlined />}
autoComplete="tel"
value={phone}
autoFocus
/>
</FormControl>
{login_captcha && (
<FormControl variant="standard" margin="normal" required fullWidth>
<Captcha generation={captchaGen} required={true} fullWidth={true} onStateChange={setCaptchaState} />
</FormControl>
)}
<FormControl variant="standard" margin="normal" required fullWidth>
<OutlineIconTextField
label={t("login.smsCode")}
variant="outlined"
inputProps={{ id: "sms-code", type: "text", name: "sms-code", required: "true" }}
onChange={(e) => onCodeChange(e.target.value)}
icon={<Password />}
value={code}
/>
</FormControl>
<Button
variant="text"
size="small"
disabled={sending || countdown > 0 || !phone || (login_captcha && !captchaState.current)}
onClick={onSend}
sx={{ mt: 1 }}
>
{countdown > 0 ? t("login.resendSmsCode", { seconds: countdown }) : t("login.sendSmsCode")}
</Button>
{control?.submit}
{control?.back}
</>
);
};
export default PhaseSmsLogin;

@ -13,6 +13,7 @@ import {
sendLogin, sendLogin,
sendPrepareLogin, sendPrepareLogin,
sendResetEmail, sendResetEmail,
sendSmsLogin,
} from "../../../../api/api.ts"; } from "../../../../api/api.ts";
import { ApiPrefix, AppError, Code } from "../../../../api/request.ts"; import { ApiPrefix, AppError, Code } from "../../../../api/request.ts";
import { AppRegistration, GrantService, LoginResponse, PrepareLoginResponse } from "../../../../api/user.ts"; import { AppRegistration, GrantService, LoginResponse, PrepareLoginResponse } from "../../../../api/user.ts";
@ -31,6 +32,7 @@ import PhaseCollectPassword from "../Phases/PhaseCollectPassword.tsx";
import PhaseConsent from "../Phases/PhaseConsent.tsx"; import PhaseConsent from "../Phases/PhaseConsent.tsx";
import PhaseForgetPassword from "../Phases/PhaseForgetPassword.tsx"; import PhaseForgetPassword from "../Phases/PhaseForgetPassword.tsx";
import PhaseSignupNeeded from "../Phases/PhaseSignupNeeded.tsx"; import PhaseSignupNeeded from "../Phases/PhaseSignupNeeded.tsx";
import PhaseSmsLogin, { SmsLoginState } from "../Phases/PhaseSmsLogin.tsx";
import "../SideTransition.css"; import "../SideTransition.css";
// Local storage key for OAuth redirect // Local storage key for OAuth redirect
@ -43,6 +45,7 @@ enum EmailLoginPhase {
Collect2FA, Collect2FA,
ForgetPassword, ForgetPassword,
Consent, Consent,
SmsLogin,
} }
export interface Control { export interface Control {
@ -96,6 +99,7 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const captchaState = useRef<CaptchaParams>(); const captchaState = useRef<CaptchaParams>();
const twoFaSession = useRef<string>(""); const twoFaSession = useRef<string>("");
const smsState = useRef<SmsLoginState>({ phone: "", code: "" });
const [loginOptions, setLoginOptions] = useState<PrepareLoginResponse>(); const [loginOptions, setLoginOptions] = useState<PrepareLoginResponse>();
// OAuth-specific state // OAuth-specific state
@ -234,6 +238,25 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
[dispatch, setPhase, setCaptchaGen, setLoading, t], [dispatch, setPhase, setCaptchaGen, setLoading, t],
); );
const smsLogin = useCallback(async () => {
try {
setLoading(true);
const loginRes = await dispatch(sendSmsLogin({ phone: smsState.current.phone, code: smsState.current.code }));
if (isOAuthFlow) {
await handleOAuthSessionSwitch(loginRes);
} else {
dispatch(refreshUserSession(loginRes, query.get("redirect")));
}
} catch (e) {
if (e instanceof AppError && e.code === Code.Continue) {
twoFaSession.current = e.response.data;
setPhase(EmailLoginPhase.Collect2FA);
}
} finally {
setLoading(false);
}
}, [dispatch, isOAuthFlow, handleOAuthSessionSwitch, query]);
const finish2FA = useCallback( const finish2FA = useCallback(
async (otp: string, ticket: string) => { async (otp: string, ticket: string) => {
try { try {
@ -293,6 +316,9 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
case EmailLoginPhase.Consent: case EmailLoginPhase.Consent:
sendConsent(); sendConsent();
break; break;
case EmailLoginPhase.SmsLogin:
smsLogin();
break;
} }
}; };
@ -404,6 +430,14 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
previous: EmailLoginPhase.CollectEmail, previous: EmailLoginPhase.CollectEmail,
}; };
break; break;
case EmailLoginPhase.SmsLogin:
phaseSetting = {
title: t("login.smsSignIn"),
nextButtonText: t("login.signIn"),
showBackButton: true,
previous: EmailLoginPhase.CollectEmail,
};
break;
default: default:
break; break;
} }
@ -503,6 +537,7 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
setEmail={setEmail} setEmail={setEmail}
control={phaseConfig.control} control={phaseConfig.control}
onOAuthPasskeyLogin={isOAuthFlow ? handleOAuthSessionSwitch : undefined} onOAuthPasskeyLogin={isOAuthFlow ? handleOAuthSessionSwitch : undefined}
onSmsLogin={() => setPhase(EmailLoginPhase.SmsLogin)}
/> />
)} )}
{phase === EmailLoginPhase.Consent && app && ( {phase === EmailLoginPhase.Consent && app && (
@ -519,6 +554,17 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
email={email} email={email}
captchaGen={captchaGen} captchaGen={captchaGen}
setCaptchaState={(s) => (captchaState.current = s)} setCaptchaState={(s) => (captchaState.current = s)}
captchaState={captchaState}
control={phaseConfig.control}
onSmsResetDone={() => setPhase(EmailLoginPhase.CollectEmail)}
/>
)}
{phase === EmailLoginPhase.SmsLogin && (
<PhaseSmsLogin
setSmsState={(s) => (smsState.current = s)}
captchaGen={captchaGen}
setCaptchaState={(s) => (captchaState.current = s)}
captchaState={captchaState}
control={phaseConfig.control} control={phaseConfig.control}
/> />
)} )}

@ -15,16 +15,19 @@ import {
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { useSnackbar } from "notistack"; import { useSnackbar } from "notistack";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
bindPhone,
sendRevokeOAuthGrant, sendRevokeOAuthGrant,
sendSmsCode,
sendUnbindSso, sendUnbindSso,
sendUpdateUserSetting, sendUpdateUserSetting,
sendVaultDisable, sendVaultDisable,
sendVaultLock, sendVaultLock,
sendVaultSetup, sendVaultSetup,
unbindPhone,
} from "../../../../api/api.ts"; } from "../../../../api/api.ts";
import { LinkedAccount, OAuthGrant, Passkey, UserSettings } from "../../../../api/user.ts"; import { LinkedAccount, OAuthGrant, Passkey, UserSettings } from "../../../../api/user.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
@ -44,6 +47,7 @@ import Edit from "../../../Icons/Edit.tsx";
import LockClosed from "../../../Icons/LockClosed.tsx"; import LockClosed from "../../../Icons/LockClosed.tsx";
import LockClosedKey from "../../../Icons/LockClosedKey.tsx"; import LockClosedKey from "../../../Icons/LockClosedKey.tsx";
import Open from "../../../Icons/Open.tsx"; import Open from "../../../Icons/Open.tsx";
import PhoneLaptopOutlined from "../../../Icons/PhoneLaptopOutlined.tsx";
import ShieldLock from "../../../Icons/ShieldLock.tsx"; import ShieldLock from "../../../Icons/ShieldLock.tsx";
import { ProfileSettingProps } from "../ProfileSetting.tsx"; import { ProfileSettingProps } from "../ProfileSetting.tsx";
import SettingForm from "../SettingForm.tsx"; import SettingForm from "../SettingForm.tsx";
@ -406,6 +410,120 @@ const VaultSetting = ({ setting, setSetting }: ProfileSettingProps) => {
); );
}; };
const PHONE_RESEND_SECONDS = 60;
const PhoneBinding = ({ setting, setSetting }: ProfileSettingProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const onSendCode = () => {
setSending(true);
dispatch(sendSmsCode({ phone, scene: "bind" }))
.then(() => setCountdown(PHONE_RESEND_SECONDS))
.finally(() => setSending(false));
};
const onBind = () => {
setLoading(true);
dispatch(bindPhone({ phone, code }))
.then(() => {
setSetting({ ...setting, phone });
setPhone("");
setCode("");
})
.finally(() => setLoading(false));
};
const onUnbind = () => {
dispatch(confirmOperation(t("setting.unbindPhoneConfirm"))).then(() => {
setLoading(true);
dispatch(unbindPhone())
.then(() => {
setSetting({ ...setting, phone: undefined });
})
.finally(() => setLoading(false));
});
};
return (
<SettingForm title={t("setting.phoneBinding")} lgWidth={5}>
{setting.phone ? (
<StyledOAuthGrantListItem sx={{ pr: "150px" }}>
<ListItemAvatar>
<Avatar>
<PhoneLaptopOutlined />
</Avatar>
</ListItemAvatar>
<StyledListItemText primary={setting.phone} />
<ListItemSecondaryAction>
<LoadingButton
loading={loading}
variant={"outlined"}
onClick={onUnbind}
startIcon={<Dismiss />}
color={"error"}
>
<span>{t("setting.unbindPhone")}</span>
</LoadingButton>
</ListItemSecondaryAction>
</StyledOAuthGrantListItem>
) : (
<Stack spacing={2} sx={{ mt: 1 }}>
<DenseFilledTextField
required
label={t("login.phoneNumber")}
fullWidth
value={phone}
onChange={(e) => setPhone(e.target.value)}
inputProps={{ type: "tel" }}
/>
<Stack direction={"row"} spacing={1}>
<DenseFilledTextField
required
label={t("login.smsCode")}
fullWidth
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<SecondaryButton
variant={"outlined"}
disabled={sending || countdown > 0 || !phone}
onClick={onSendCode}
sx={{ whiteSpace: "nowrap" }}
>
{countdown > 0 ? t("login.resendSmsCode", { seconds: countdown }) : t("login.sendSmsCode")}
</SecondaryButton>
</Stack>
<Box>
<LoadingButton
variant={"contained"}
onClick={onBind}
loading={loading}
disabled={!phone || code.length !== 6}
startIcon={<PhoneLaptopOutlined />}
>
<span>{t("setting.bindPhone")}</span>
</LoadingButton>
</Box>
</Stack>
)}
</SettingForm>
);
};
const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => { const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
@ -417,6 +535,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
const authEnabled = useAppSelector((s) => s.siteConfig.login.config.authn); const authEnabled = useAppSelector((s) => s.siteConfig.login.config.authn);
const qqConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.qq_connect_enabled); const qqConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.qq_connect_enabled);
const wechatConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.wechat_connect_enabled); const wechatConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.wechat_connect_enabled);
const smsEnabled = useAppSelector((s) => s.siteConfig.login.config.sms_enabled);
const resetPwdFormRef = React.createRef<HTMLFormElement>(); const resetPwdFormRef = React.createRef<HTMLFormElement>();
const [showResetPassword, setShowResetPassword] = useState(false); const [showResetPassword, setShowResetPassword] = useState(false);
@ -652,6 +771,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
)} )}
</SettingForm> </SettingForm>
)} )}
{smsEnabled && <PhoneBinding setting={setting} setSetting={setSetting} />}
<VaultSetting setting={setting} setSetting={setSetting} /> <VaultSetting setting={setting} setSetting={setSetting} />
<Enable2FADialog open={enable2FAOpen} onClose={() => setEnable2FAOpen(false)} on2FAEnabled={on2FAChange(true)} /> <Enable2FADialog open={enable2FAOpen} onClose={() => setEnable2FAOpen(false)} on2FAEnabled={on2FAChange(true)} />
<Disable2FADialog <Disable2FADialog

@ -574,6 +574,12 @@ var DefaultSettings = map[string]string{
"wechat_connect_app_id": "", "wechat_connect_app_id": "",
"wechat_connect_app_secret": "", "wechat_connect_app_secret": "",
"wechat_connect_register_enabled": "1", "wechat_connect_register_enabled": "1",
"sms_enabled": "0",
"sms_endpoint": "",
"sms_method": "POST",
"sms_headers": "",
"sms_body_tpl": `{"phone":"{phone}","code":"{code}"}`,
"sms_register_enabled": "1",
"upload_dedup_scope": "owner", "upload_dedup_scope": "owner",
"download_cdn_routes": "", "download_cdn_routes": "",
"download_cdn_shuffle": "0", "download_cdn_shuffle": "0",

@ -56,6 +56,9 @@ type (
Create(ctx context.Context, args *NewUserArgs) (*ent.User, error) Create(ctx context.Context, args *NewUserArgs) (*ent.User, error)
// GetByEmail get the user with given email, user status is ignored. // GetByEmail get the user with given email, user status is ignored.
GetByEmail(ctx context.Context, email string) (*ent.User, error) GetByEmail(ctx context.Context, email string) (*ent.User, error)
// GetByPhone get the user with given phone number, user status is
// ignored; returns ent.IsNotFound-compatible error when unbound.
GetByPhone(ctx context.Context, phone string) (*ent.User, error)
// GetByID get user by its ID, user status is ignored. // GetByID get user by its ID, user status is ignored.
GetByID(ctx context.Context, id int) (*ent.User, error) GetByID(ctx context.Context, id int) (*ent.User, error)
// ListByIDs returns users for the given IDs, keyed by user ID. // ListByIDs returns users for the given IDs, keyed by user ID.
@ -92,6 +95,8 @@ type (
UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error) UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error)
// UpdateNickname updates user nickname. // UpdateNickname updates user nickname.
UpdateNickname(ctx context.Context, u *ent.User, name string) (*ent.User, error) UpdateNickname(ctx context.Context, u *ent.User, name string) (*ent.User, error)
// SetPhone binds a phone number to the user; empty clears the binding.
SetPhone(ctx context.Context, u *ent.User, phone string) (*ent.User, error)
// UpdatePassword updates user password. // UpdatePassword updates user password.
UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error) UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error)
// UpdateVault sets or clears the user's private-space credential and // UpdateVault sets or clears the user's private-space credential and
@ -161,6 +166,7 @@ type (
GroupID int GroupID int
Avatar string // Optional Avatar string // Optional
Language string // Optional Language string // Optional
Phone string // Optional, bound phone number
} }
CreateStoragePackArgs struct { CreateStoragePackArgs struct {
UserID int UserID int
@ -181,6 +187,14 @@ func (c *userClient) UpdateNickname(ctx context.Context, u *ent.User, name strin
return c.client.User.UpdateOne(u).SetNick(name).Save(ctx) return c.client.User.UpdateOne(u).SetNick(name).Save(ctx)
} }
func (c *userClient) SetPhone(ctx context.Context, u *ent.User, phone string) (*ent.User, error) {
stm := c.client.User.UpdateOne(u)
if phone == "" {
return stm.ClearPhone().Save(ctx)
}
return stm.SetPhone(phone).Save(ctx)
}
func (c *userClient) UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error) { func (c *userClient) UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error) {
return c.client.User.UpdateOne(u).SetAvatar(avatar).Save(ctx) return c.client.User.UpdateOne(u).SetAvatar(avatar).Save(ctx)
} }
@ -513,6 +527,9 @@ func (c *userClient) Create(ctx context.Context, args *NewUserArgs) (*ent.User,
if args.Language != "" { if args.Language != "" {
userSetting.Language = args.Language userSetting.Language = args.Language
} }
if args.Phone != "" {
query.SetPhone(args.Phone)
}
query.SetSettings(userSetting) query.SetSettings(userSetting)
// Create user // Create user
@ -535,6 +552,10 @@ func (c *userClient) GetByEmail(ctx context.Context, email string) (*ent.User, e
return withUserEagerLoading(ctx, c.client.User.Query().Where(user.EmailEqualFold(email))).First(ctx) return withUserEagerLoading(ctx, c.client.User.Query().Where(user.EmailEqualFold(email))).First(ctx)
} }
func (c *userClient) GetByPhone(ctx context.Context, phone string) (*ent.User, error) {
return withUserEagerLoading(ctx, c.client.User.Query().Where(user.PhoneEQ(phone))).First(ctx)
}
func (c *userClient) GetByID(ctx context.Context, id int) (*ent.User, error) { func (c *userClient) GetByID(ctx context.Context, id int) (*ent.User, error) {
return withUserEagerLoading(ctx, c.client.User.Query().Where(user.ID(id))).First(ctx) return withUserEagerLoading(ctx, c.client.User.Query().Where(user.ID(id))).First(ctx)
} }

@ -266,6 +266,10 @@ const (
// CodeVaultLocked 私密空间未解锁 // CodeVaultLocked 私密空间未解锁
CodeVaultLocked = 40093 CodeVaultLocked = 40093
// CodeSmsCodeErr 短信验证码错误或已过期
CodeSmsCodeErr = 40095
// CodeFailedSendSms 短信发送失败
CodeFailedSendSms = 40096
// CodeDBError 数据库操作失败 // CodeDBError 数据库操作失败
CodeDBError = 50001 CodeDBError = 50001
// CodeEncryptError 加密失败 // CodeEncryptError 加密失败

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"net/http"
"net/url" "net/url"
"sort" "sort"
"strconv" "strconv"
@ -262,6 +263,9 @@ type (
SSO(ctx context.Context) *SSO SSO(ctx context.Context) *SSO
QQConnect(ctx context.Context) *QQConnect QQConnect(ctx context.Context) *QQConnect
WeChatConnect(ctx context.Context) *WeChatConnect WeChatConnect(ctx context.Context) *WeChatConnect
// SmsGateway returns the generic HTTP SMS gateway config for
// phone verification codes.
SmsGateway(ctx context.Context) *SmsGateway
// EmailFilter returns the sign-up email restriction settings. // EmailFilter returns the sign-up email restriction settings.
EmailFilter(ctx context.Context) *EmailFilter EmailFilter(ctx context.Context) *EmailFilter
// ShareDefaults returns the site-wide share defaults applied when a // ShareDefaults returns the site-wide share defaults applied when a
@ -1024,6 +1028,21 @@ func (s *settingProvider) WeChatConnect(ctx context.Context) *WeChatConnect {
} }
} }
func (s *settingProvider) SmsGateway(ctx context.Context) *SmsGateway {
method := strings.ToUpper(s.getString(ctx, "sms_method", "POST"))
if method != http.MethodGet && method != http.MethodPost {
method = http.MethodPost
}
return &SmsGateway{
Enabled: s.getBoolean(ctx, "sms_enabled", false),
Endpoint: s.getString(ctx, "sms_endpoint", ""),
Method: method,
Headers: s.getString(ctx, "sms_headers", ""),
BodyTemplate: s.getString(ctx, "sms_body_tpl", `{"phone":"{phone}","code":"{code}"}`),
RegisterEnabled: s.getBoolean(ctx, "sms_register_enabled", true),
}
}
func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter { func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter {
mode := EmailFilterMode(s.getInt(ctx, "email_filter_mode", 0)) mode := EmailFilterMode(s.getInt(ctx, "email_filter_mode", 0))
if mode < EmailFilterDisabled || mode > EmailFilterBlacklist { if mode < EmailFilterDisabled || mode > EmailFilterBlacklist {

@ -105,6 +105,18 @@ type WeChatConnect struct {
RegisterEnabled bool RegisterEnabled bool
} }
// SmsGateway holds the generic HTTP SMS gateway config used for phone
// verification codes. Endpoint/BodyTemplate accept `{phone}` and `{code}`
// placeholders; Headers is a newline-separated `Key: Value` list.
type SmsGateway struct {
Enabled bool
Endpoint string
Method string
Headers string
BodyTemplate string
RegisterEnabled bool
}
type EmailFilterMode int type EmailFilterMode int
const ( const (

@ -0,0 +1,70 @@
// Package sms delivers verification codes through a generic HTTP SMS
// gateway. The endpoint URL and request body are admin-configured
// templates accepting `{phone}` and `{code}` placeholders, so any
// provider reachable over plain HTTP can be integrated without a
// vendor SDK.
package sms
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
)
// SendCode dispatches a verification code through the configured gateway.
// For GET gateways the rendered endpoint is requested with no body; for
// POST the rendered body template is sent with the configured headers.
func SendCode(ctx context.Context, client request.Client, gw *setting.SmsGateway, phone, code string) error {
if !gw.Enabled || gw.Endpoint == "" {
return fmt.Errorf("sms gateway not enabled or not configured")
}
render := func(tpl string) string {
return strings.NewReplacer("{phone}", phone, "{code}", code).Replace(tpl)
}
endpoint := render(gw.Endpoint)
// The endpoint is admin-configured, but DNS can still resolve it to a
// private address — reject before issuing the request.
if err := request.ValidateExternalURL(ctx, endpoint, request.SSRFOptions{}); err != nil {
return fmt.Errorf("sms endpoint rejected: %w", err)
}
header := http.Header{}
for _, line := range strings.Split(gw.Headers, "\n") {
k, v, ok := strings.Cut(line, ":")
if k = strings.TrimSpace(k); !ok || k == "" {
continue
}
header.Set(k, strings.TrimSpace(v))
}
var body *strings.Reader
method := gw.Method
if method == http.MethodGet {
body = strings.NewReader("")
} else {
method = http.MethodPost
body = strings.NewReader(render(gw.BodyTemplate))
}
resp, err := client.
Request(method, endpoint, body,
request.WithContext(ctx),
request.WithTimeout(15*time.Second),
request.WithHeader(header),
request.WithContentLength(int64(body.Len())),
).
CheckHTTPResponse(http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent).
GetResponse()
if err != nil {
return fmt.Errorf("sms gateway request failed: %w", err)
}
_ = resp
return nil
}

@ -97,6 +97,61 @@ func UserLogin2FAValidation(c *gin.Context) {
c.Next() c.Next()
} }
// UserSmsLogin validates a phone verification code for sign-in
func UserSmsLogin(c *gin.Context) {
service := ParametersFromContext[*user.SmsLoginService](c, user.SmsLoginParameterCtx{})
expectedUser, twoFaSession, err := service.Login(c)
if respondErr(c, err) {
return
}
if twoFaSession == "" {
util.WithValue(c, inventory.UserCtx{}, expectedUser)
c.Next()
return
}
c.JSON(200, serializer.Response{Code: serializer.CodeNotFullySuccess, Data: twoFaSession})
c.Abort()
}
// UserSendSmsCode dispatches an SMS verification code
func UserSendSmsCode(c *gin.Context) {
service := ParametersFromContext[*user.SmsSendCodeService](c, user.SmsSendCodeParameterCtx{})
if err := service.Send(c); respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserSmsReset resets password via a phone verification code
func UserSmsReset(c *gin.Context) {
service := ParametersFromContext[*user.SmsResetService](c, user.SmsResetParameterCtx{})
res, err := service.Reset(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
}
// UserBindPhone binds a verified phone number to the current user
func UserBindPhone(c *gin.Context) {
service := ParametersFromContext[*user.SmsBindService](c, user.SmsBindParameterCtx{})
if err := service.Bind(c); respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserUnbindPhone clears the current user's phone binding
func UserUnbindPhone(c *gin.Context) {
service := ParametersFromContext[*user.SmsUnbindService](c, user.SmsUnbindParameterCtx{})
if err := service.Unbind(c); respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserIssueToken generates new token pair for user // UserIssueToken generates new token pair for user
func UserIssueToken(c *gin.Context) { func UserIssueToken(c *gin.Context) {
resp, err := user.IssueToken(c) resp, err := user.IssueToken(c)

@ -342,6 +342,27 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
) )
} }
// SMS verification-code sign-in
smsRouter := session.Group("sms")
{
// Send a verification code (login/bind/reset scenes)
smsRouter.POST("send",
middleware.RateLimitByIP("sms_send", 5, time.Minute),
middleware.CaptchaRequired(func(c *gin.Context) bool {
return dep.SettingProvider().LoginCaptchaEnabled(c)
}),
controllers.FromJSON[usersvc.SmsSendCodeService](usersvc.SmsSendCodeParameterCtx{}),
controllers.UserSendSmsCode,
)
// Sign in with phone + code
smsRouter.POST("login",
middleware.RateLimitByIP("sms_login", 10, time.Minute),
controllers.FromJSON[usersvc.SmsLoginService](usersvc.SmsLoginParameterCtx{}),
controllers.UserSmsLogin,
controllers.UserIssueToken,
)
}
// QQ Connect (non-OIDC OAuth2 provider) // QQ Connect (non-OIDC OAuth2 provider)
qqRouter := session.Group("qq") qqRouter := session.Group("qq")
{ {
@ -452,6 +473,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[usersvc.UserResetEmailService](usersvc.UserResetEmailParameterCtx{}), controllers.FromJSON[usersvc.UserResetEmailService](usersvc.UserResetEmailParameterCtx{}),
controllers.UserSendReset, controllers.UserSendReset,
) )
// 通过短信验证码重设密码
user.POST("reset_sms",
middleware.RateLimitByIP("reset_sms", 10, time.Minute),
controllers.FromJSON[usersvc.SmsResetService](usersvc.SmsResetParameterCtx{}),
controllers.UserSmsReset,
)
// 邮件激活 Done // 邮件激活 Done
user.GET("activate/:id", user.GET("activate/:id",
middleware.SignRequired(dep.GeneralAuth()), middleware.SignRequired(dep.GeneralAuth()),
@ -1574,6 +1601,19 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[usersvc.RequestEmailChangeService](usersvc.RequestEmailChangeParamCtx{}), controllers.FromJSON[usersvc.RequestEmailChangeService](usersvc.RequestEmailChangeParamCtx{}),
controllers.UserRequestEmailChange, controllers.UserRequestEmailChange,
) )
// 绑定手机号(短信验证码校验)
setting.PUT("phone",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
middleware.RateLimitByIP("phone_bind", 10, time.Hour),
controllers.FromJSON[usersvc.SmsBindService](usersvc.SmsBindParameterCtx{}),
controllers.UserBindPhone,
)
// 解绑手机号
setting.DELETE("phone",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
controllers.FromJSON[usersvc.SmsUnbindService](usersvc.SmsUnbindParameterCtx{}),
controllers.UserUnbindPhone,
)
// 解除外部账号绑定(QQ Connect 等) // 解除外部账号绑定(QQ Connect 等)
setting.DELETE("sso_binding/:provider", setting.DELETE("sso_binding/:provider",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite), middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),

@ -55,6 +55,8 @@ type SiteConfig struct {
SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"` SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"`
QQConnectEnabled bool `json:"qq_connect_enabled,omitempty"` QQConnectEnabled bool `json:"qq_connect_enabled,omitempty"`
WeChatEnabled bool `json:"wechat_connect_enabled,omitempty"` WeChatEnabled bool `json:"wechat_connect_enabled,omitempty"`
// SmsEnabled tells the login UI to offer phone + code sign-in.
SmsEnabled bool `json:"sms_enabled,omitempty"`
// DownloadCDNRoutes exposes configured CDN mirror endpoints so clients // DownloadCDNRoutes exposes configured CDN mirror endpoints so clients
// can offer a download-route picker (#2987). // can offer a download-route picker (#2987).
@ -152,6 +154,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
SSOAutoRedirect: sso.AutoRedirect, SSOAutoRedirect: sso.AutoRedirect,
QQConnectEnabled: qq.Enabled && qq.AppID != "", QQConnectEnabled: qq.Enabled && qq.AppID != "",
WeChatEnabled: wx.Enabled && wx.AppID != "", WeChatEnabled: wx.Enabled && wx.AppID != "",
SmsEnabled: settings.SmsGateway(c).Enabled && settings.SmsGateway(c).Endpoint != "",
}, nil }, nil
case "explorer": case "explorer":
explorerSettings := settings.ExplorerFrontendSettings(c) explorerSettings := settings.ExplorerFrontendSettings(c)

@ -43,6 +43,8 @@ type UserSettings struct {
VaultEnabled bool `json:"vault_enabled"` VaultEnabled bool `json:"vault_enabled"`
// VaultUnlocked reports whether an unlock session is currently active. // VaultUnlocked reports whether an unlock session is currently active.
VaultUnlocked bool `json:"vault_unlocked"` VaultUnlocked bool `json:"vault_unlocked"`
// Phone is the bound mobile number, masked for display.
Phone string `json:"phone,omitempty"`
} }
// LinkedAccount is an external identity bound to the local account // LinkedAccount is an external identity bound to the local account
@ -75,9 +77,19 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
return LinkedAccount{Provider: item.Provider, CreatedAt: item.CreatedAt} return LinkedAccount{Provider: item.Provider, CreatedAt: item.CreatedAt}
}), }),
VaultEnabled: u.VaultFolder > 0, VaultEnabled: u.VaultFolder > 0,
Phone: maskPhone(u.Phone),
} }
} }
// maskPhone redacts the middle of a bound phone number for display.
func maskPhone(phone *string) string {
if phone == nil || len(*phone) < 5 {
return ""
}
p := *phone
return p[:3] + "****" + p[len(p)-2:]
}
type Passkey struct { type Passkey struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`

@ -0,0 +1,322 @@
package user
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"regexp"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/sms"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
)
type (
// SmsSendCodeService dispatches a verification code to a phone number.
SmsSendCodeService struct {
Phone string `json:"phone" binding:"required,max=20"`
Scene string `json:"scene" binding:"required,oneof=login bind reset"`
}
SmsSendCodeParameterCtx struct{}
// SmsLoginService signs in (or provisions) a user by phone + code.
SmsLoginService struct {
Phone string `json:"phone" binding:"required,max=20"`
Code string `json:"code" binding:"required,len=6"`
}
SmsLoginParameterCtx struct{}
// SmsResetService resets the password of the phone-bound account.
SmsResetService struct {
Phone string `json:"phone" binding:"required,max=20"`
Code string `json:"code" binding:"required,len=6"`
Password string `json:"password" binding:"required,min=6,max=128"`
}
SmsResetParameterCtx struct{}
// SmsBindService binds a verified phone to the signed-in user.
SmsBindService struct {
Phone string `json:"phone" binding:"required,max=20"`
Code string `json:"code" binding:"required,len=6"`
}
SmsBindParameterCtx struct{}
// SmsUnbindService clears the signed-in user's phone binding.
SmsUnbindService struct{}
SmsUnbindParameterCtx struct{}
)
const (
smsCodeTTLSeconds = 300
smsSendThrottleSec = 60
smsCodePrefix = "sms_code_"
smsThrottlePrefix = "sms_rl_"
// smsMailDomain hosts synthetic addresses for phone-provisioned
// accounts, mirroring the connect.qq/wechat convention.
smsMailDomain = "sms.local"
)
// Permissive E.164-ish shape: optional country-code plus 5–15 digits.
var phonePattern = regexp.MustCompile(`^\+?[0-9]{5,15}$`)
func normalizePhone(raw string) (string, error) {
p := strings.TrimSpace(strings.ReplaceAll(raw, " ", ""))
if !phonePattern.MatchString(p) {
return "", serializer.NewError(serializer.CodeParamErr, "Invalid phone number", nil)
}
return p, nil
}
func smsCodeKey(scene, phone string) string { return smsCodePrefix + scene + "_" + phone }
func smsCode() string {
// 6-digit numeric code from crypto/rand.
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "000000"
}
return fmt.Sprintf("%06d", n.Int64())
}
// verifySmsCode consumes a stored code; single-use on success.
func verifySmsCode(c *gin.Context, dep dependency.Dep, scene, phone, code string) error {
raw, ok := dep.KV().Get(smsCodeKey(scene, phone))
stored, isStr := raw.(string)
if !ok || !isStr || stored != code {
return serializer.NewError(serializer.CodeSmsCodeErr, "Incorrect or expired verification code", nil)
}
if err := dep.KV().Delete(smsCodePrefix, scene+"_"+phone); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to consume verification code", err)
}
return nil
}
// Send issues a verification code for the requested scene.
func (service *SmsSendCodeService) Send(c *gin.Context) error {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
gw := settings.SmsGateway(c)
if !gw.Enabled || gw.Endpoint == "" {
return serializer.NewError(serializer.CodeFeatureNotEnabled, "SMS sign-in is not enabled", nil)
}
phone, err := normalizePhone(service.Phone)
if err != nil {
return err
}
userClient := dep.UserClient()
existing, lookupErr := userClient.GetByPhone(c, phone)
switch service.Scene {
case "reset":
// Consistent with the email reset flow: surface "not found" so the
// UI can tell the user the number is unbound.
if lookupErr != nil {
return serializer.NewError(serializer.CodeUserNotFound, "No account bound to this phone", lookupErr)
}
case "bind":
// The number must be free before a code is worth sending.
if lookupErr == nil && existing.ID != inventory.UserIDFromContext(c) {
return serializer.NewError(serializer.CodeConflict, "Phone already bound to another account", nil)
}
case "login":
// Unknown numbers may still proceed when auto-provisioning is on.
if lookupErr != nil && !gw.RegisterEnabled {
return serializer.NewError(serializer.CodeUserNotFound, "No account bound to this phone", lookupErr)
}
}
throttleKey := smsThrottlePrefix + phone
if _, ok := dep.KV().Get(throttleKey); ok {
return serializer.NewError(serializer.CodeRateLimited, "Verification code already sent, please wait", nil)
}
code := smsCode()
if err := dep.KV().Set(smsCodeKey(service.Scene, phone), code, smsCodeTTLSeconds); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to store verification code", err)
}
if err := sms.SendCode(c, dep.RequestClient(), gw, phone, code); err != nil {
_ = dep.KV().Delete(smsCodePrefix, service.Scene+"_"+phone)
dep.Logger().Warning("SMS send failed: %s", err)
return serializer.NewError(serializer.CodeFailedSendSms, "Failed to send verification code", err)
}
_ = dep.KV().Set(throttleKey, true, smsSendThrottleSec)
return nil
}
// Login verifies the code, then signs in or provisions the account — same
// return contract as UserLoginService.Login so UserIssueToken applies
// (including the 2FA continuation).
func (service *SmsLoginService) Login(c *gin.Context) (*ent.User, string, error) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
gw := settings.SmsGateway(c)
if !gw.Enabled || gw.Endpoint == "" {
return nil, "", serializer.NewError(serializer.CodeFeatureNotEnabled, "SMS sign-in is not enabled", nil)
}
phone, err := normalizePhone(service.Phone)
if err != nil {
return nil, "", err
}
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
expectedUser, err := userClient.GetByPhone(ctx, phone)
var loginFailed error
switch {
case err != nil && !ent.IsNotFound(err):
loginFailed = serializer.NewError(serializer.CodeDBError, "Failed to query user", err)
case ent.IsNotFound(err):
if !gw.RegisterEnabled || !settings.RegisterEnabled(c) {
loginFailed = serializer.NewError(serializer.CodeUserNotFound, "No account bound to this phone", nil)
} else if expectedUser, err = smsProvisionUser(c, dep, phone); err != nil {
loginFailed = serializer.NewError(serializer.CodeDBError, "Failed to create account", err)
} else {
expectedUser, err = userClient.GetByID(ctx, expectedUser.ID)
if err != nil {
loginFailed = serializer.NewError(serializer.CodeDBError, "Failed to load user", err)
}
}
case err == nil:
expectedUser, err = userClient.LiftExpiredBan(ctx, expectedUser)
if err != nil {
loginFailed = serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err)
} else if expectedUser.Status == user.StatusManualBanned || expectedUser.Status == user.StatusSysBanned {
loginFailed = banError(expectedUser, "This account has been blocked")
} else if expectedUser.Status == user.StatusInactive {
loginFailed = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil)
} else if ipErr := checkLoginIPWhitelist(c.ClientIP(), expectedUser.Edges.Group); ipErr != nil {
loginFailed = ipErr
}
}
if loginFailed != nil {
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventUserLoginFailed,
activity.Extra(map[string]any{"phone": phone}))
return nil, "", loginFailed
}
// Verify only after user resolution — a valid code on a banned account
// must not be consumed.
if err := verifySmsCode(c, dep, "login", phone, service.Code); err != nil {
return nil, "", err
}
if expectedUser.TwoFactorSecret != "" {
twoFaSessionID := uuid.Must(uuid.NewV4())
dep.KV().Set(fmt.Sprintf("user_2fa_%s", twoFaSessionID), expectedUser.ID, 600)
return expectedUser, twoFaSessionID.String(), nil
}
return expectedUser, "", nil
}
// Reset verifies the code and updates the phone-bound user's password.
func (service *SmsResetService) Reset(c *gin.Context) (*User, error) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
if gw := settings.SmsGateway(c); !gw.Enabled || gw.Endpoint == "" {
return nil, serializer.NewError(serializer.CodeFeatureNotEnabled, "SMS sign-in is not enabled", nil)
}
phone, err := normalizePhone(service.Phone)
if err != nil {
return nil, err
}
userClient := dep.UserClient()
u, err := userClient.GetByPhone(c, phone)
if err != nil {
return nil, serializer.NewError(serializer.CodeUserNotFound, "No account bound to this phone", err)
}
if u, err = userClient.LiftExpiredBan(c, u); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err)
}
if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
return nil, banError(u, "This user is banned")
}
if u.Status == user.StatusInactive {
return nil, serializer.NewError(serializer.CodeUserNotActivated, "This user is not activated", nil)
}
if err := verifySmsCode(c, dep, "reset", phone, service.Code); err != nil {
return nil, err
}
u, err = userClient.UpdatePassword(c, u, service.Password)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to update password", err)
}
userRes := BuildUser(u, dep.HashIDEncoder())
return &userRes, nil
}
// Bind attaches the verified phone to the signed-in user.
func (service *SmsBindService) Bind(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
if u == nil || inventory.IsAnonymousUser(u) {
return serializer.NewError(serializer.CodeCheckLogin, "Please sign in", nil)
}
if gw := dep.SettingProvider().SmsGateway(c); !gw.Enabled {
return serializer.NewError(serializer.CodeFeatureNotEnabled, "SMS sign-in is not enabled", nil)
}
phone, err := normalizePhone(service.Phone)
if err != nil {
return err
}
if err := verifySmsCode(c, dep, "bind", phone, service.Code); err != nil {
return err
}
if _, err := dep.UserClient().SetPhone(c, u, phone); err != nil {
return serializer.NewError(serializer.CodeConflict, "Failed to bind phone; it may already be in use", err)
}
recordUserEvent(c, dep, u.ID, types.EventLinkAccount, map[string]any{"provider": "sms"})
return nil
}
// Unbind clears the signed-in user's phone binding.
func (service *SmsUnbindService) Unbind(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
if u == nil || inventory.IsAnonymousUser(u) {
return serializer.NewError(serializer.CodeCheckLogin, "Please sign in", nil)
}
if _, err := dep.UserClient().SetPhone(c, u, ""); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to unbind phone", err)
}
recordUserEvent(c, dep, u.ID, types.EventUnlinkAccount, map[string]any{"provider": "sms"})
return nil
}
// smsProvisionUser creates a local account for a first-time phone login.
// The phone has no email claim, so a synthetic address under sms.local is
// used and the phone doubles as the initial nickname.
func smsProvisionUser(c *gin.Context, dep dependency.Dep, phone string) (*ent.User, error) {
return dep.UserClient().Create(c, &inventory.NewUserArgs{
Email: fmt.Sprintf("sms_%s@%s", strings.TrimPrefix(phone, "+"), smsMailDomain),
Nick: "Mobile user " + phone,
Status: user.StatusActive,
GroupID: dep.SettingProvider().DefaultGroup(c),
Phone: phone,
})
}

@ -0,0 +1,351 @@
package user
import (
"context"
"net/http/httptest"
"testing"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type smsSettingProvider struct {
setting.Provider
gw *setting.SmsGateway
registerEnabled bool
defaultGroup int
}
func (p smsSettingProvider) SmsGateway(context.Context) *setting.SmsGateway { return p.gw }
func (p smsSettingProvider) RegisterEnabled(context.Context) bool { return p.registerEnabled }
func (p smsSettingProvider) DefaultGroup(context.Context) int { return p.defaultGroup }
func (p smsSettingProvider) AuditLogEnabled(context.Context, int) bool { return false }
func (p smsSettingProvider) HashIDSalt(context.Context) string { return "" }
func smsDep(t *testing.T, client *ent.Client, kv cache.Driver, p setting.Provider) dependency.Dep {
t.Helper()
logger := logging.NewConsoleLogger(logging.LevelError)
cfg, err := conf.NewIniConfigProvider(t.TempDir()+"/conf.ini", logger)
require.NoError(t, err)
return dependency.NewDependency(
dependency.WithDbClient(client),
dependency.WithUserClient(inventory.NewUserClient(client)),
dependency.WithKV(kv),
dependency.WithConfigProvider(cfg),
dependency.WithLogger(logger),
dependency.WithSettingProvider(p),
)
}
func smsCtx(dep dependency.Dep, u *ent.User) *gin.Context {
engine := gin.New()
engine.ContextWithFallback = true
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
c.Request = httptest.NewRequest("POST", "/", nil)
util.WithValue(c, dependency.DepCtx{}, dep)
if u != nil {
util.WithValue(c, inventory.UserCtx{}, u)
}
return c
}
func enabledSmsProvider(defaultGroup int) smsSettingProvider {
return smsSettingProvider{
gw: &setting.SmsGateway{
Enabled: true,
Endpoint: "https://sms.example.com/send?phone={phone}&code={code}",
Method: "GET",
RegisterEnabled: true,
},
registerEnabled: true,
defaultGroup: defaultGroup,
}
}
func TestNormalizePhone(t *testing.T) {
for _, ok := range []string{"+8613912345678", "13912345678", " +1 555 0100 "} {
p, err := normalizePhone(ok)
require.NoError(t, err)
require.NotEmpty(t, p)
}
for _, bad := range []string{"", "+", "1234", "abc123456", "+86139123456789012", "phone", "+1-555-0100"} {
_, err := normalizePhone(bad)
require.Error(t, err, bad)
}
}
func TestMaskPhone(t *testing.T) {
require.Equal(t, "", maskPhone(nil))
short := "+123"
require.Equal(t, "", maskPhone(&short))
full := "+8613912345678"
require.Equal(t, "+86****78", maskPhone(&full))
}
// TestVerifySmsCode exercises the KV-backed single-use code contract.
func TestVerifySmsCode(t *testing.T) {
gin.SetMode(gin.TestMode)
kv := cache.NewMemoStore("", nil)
dep := dependency.NewDependency(dependency.WithKV(kv))
c := smsCtx(dep, nil)
phone := "+8613912345678"
require.NoError(t, kv.Set(smsCodeKey("login", phone), "123456", smsCodeTTLSeconds))
// Wrong code rejected; stored code survives a failed attempt.
require.Error(t, verifySmsCode(c, dep, "login", phone, "654321"))
require.NoError(t, verifySmsCode(c, dep, "login", phone, "123456"))
// Replay after success is rejected (consumed).
require.Error(t, verifySmsCode(c, dep, "login", phone, "123456"))
// Wrong scene does not match.
require.NoError(t, kv.Set(smsCodeKey("bind", phone), "999999", smsCodeTTLSeconds))
require.Error(t, verifySmsCode(c, dep, "login", phone, "999999"))
require.NoError(t, verifySmsCode(c, dep, "bind", phone, "999999"))
// Non-string KV values are rejected instead of panicking.
require.NoError(t, kv.Set(smsCodeKey("reset", phone), 123456, smsCodeTTLSeconds))
require.Error(t, verifySmsCode(c, dep, "reset", phone, "123456"))
}
// TestSmsSendDisabled ensures every entry point refuses when the gateway is off.
func TestSmsSendDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().SetEmail("u@example.com").SetNick("u").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := smsDep(t, client, kv, smsSettingProvider{
gw: &setting.SmsGateway{Enabled: false},
defaultGroup: group.ID,
})
err := (&SmsSendCodeService{Phone: "+8613912345678", Scene: "login"}).Send(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeFeatureNotEnabled)
_, _, err = (&SmsLoginService{Phone: "+8613912345678", Code: "123456"}).Login(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeFeatureNotEnabled)
_, err = (&SmsResetService{Phone: "+8613912345678", Code: "123456", Password: "secret1"}).Reset(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeFeatureNotEnabled)
err = (&SmsBindService{Phone: "+8613912345678", Code: "123456"}).Bind(smsCtx(dep, u))
requireAppCode(t, err, serializer.CodeFeatureNotEnabled)
}
// TestSmsSendFailures covers validation, unknown-phone and send-failure paths.
// The endpoint intentionally points at an unroutable host so the gateway call
// fails after the code is staged; the staged code must be cleaned up.
func TestSmsSendFailures(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
client.User.Create().SetEmail("u@example.com").SetNick("u").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).
SetPhone("+8613912345678").SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := smsDep(t, client, kv, smsSettingProvider{
gw: &setting.SmsGateway{
Enabled: true,
Endpoint: "http://127.0.0.1:1/sms?phone={phone}&code={code}",
Method: "GET",
RegisterEnabled: true,
},
registerEnabled: true,
defaultGroup: group.ID,
})
// Malformed phone.
err := (&SmsSendCodeService{Phone: "not-a-phone", Scene: "login"}).Send(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeParamErr)
// Reset for an unbound number → user not found.
err = (&SmsSendCodeService{Phone: "+8610000000000", Scene: "reset"}).Send(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeUserNotFound)
// Gateway send fails; the staged code must not linger.
err = (&SmsSendCodeService{Phone: "+8613912345678", Scene: "login"}).Send(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeFailedSendSms)
_, ok := kv.Get(smsCodeKey("login", "+8613912345678"))
require.False(t, ok)
_, ok = kv.Get(smsThrottlePrefix + "+8613912345678")
require.False(t, ok)
}
func TestSmsLogin(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().SetEmail("u@example.com").SetNick("u").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).
SetPhone("+8613912345678").SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := smsDep(t, client, kv, enabledSmsProvider(group.ID))
// Unknown code → error, user not returned.
_, _, err := (&SmsLoginService{Phone: "+8613912345678", Code: "000000"}).Login(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeSmsCodeErr)
// Correct code → signed in.
require.NoError(t, kv.Set(smsCodeKey("login", "+8613912345678"), "123456", smsCodeTTLSeconds))
got, twoFA, err := (&SmsLoginService{Phone: "+8613912345678", Code: "123456"}).Login(smsCtx(dep, nil))
require.NoError(t, err)
require.Equal(t, u.ID, got.ID)
require.Empty(t, twoFA)
// Auto-provision a new account for an unknown phone.
require.NoError(t, kv.Set(smsCodeKey("login", "+15550199"), "654321", smsCodeTTLSeconds))
got, _, err = (&SmsLoginService{Phone: "+15550199", Code: "654321"}).Login(smsCtx(dep, nil))
require.NoError(t, err)
require.Equal(t, "+15550199", *got.Phone)
require.Equal(t, "sms_15550199@sms.local", got.Email)
// Unknown phone with registration disabled → user not found, code not
// consumed (verify runs after user resolution).
disabled := smsSettingProvider{
gw: &setting.SmsGateway{Enabled: true, Endpoint: "https://x.test", RegisterEnabled: false},
registerEnabled: true,
defaultGroup: group.ID,
}
dep2 := smsDep(t, client, kv, disabled)
require.NoError(t, kv.Set(smsCodeKey("login", "+15550000"), "111111", smsCodeTTLSeconds))
_, _, err = (&SmsLoginService{Phone: "+15550000", Code: "111111"}).Login(smsCtx(dep2, nil))
requireAppCode(t, err, serializer.CodeUserNotFound)
_, ok := kv.Get(smsCodeKey("login", "+15550000"))
require.True(t, ok, "code must survive a failed login")
// Banned user is rejected and the code is not consumed.
banned := client.User.Create().SetEmail("b@example.com").SetNick("b").SetGroup(group).
SetStatus(user.StatusManualBanned).SetSettings(&types.UserSetting{}).
SetPhone("+8610000000001").SaveX(ctx)
require.NoError(t, kv.Set(smsCodeKey("login", "+8610000000001"), "222222", smsCodeTTLSeconds))
_, _, err = (&SmsLoginService{Phone: "+8610000000001", Code: "222222"}).Login(smsCtx(dep, nil))
require.Error(t, err)
_, ok = kv.Get(smsCodeKey("login", "+8610000000001"))
require.True(t, ok)
_ = banned
}
// TestSmsLogin2FA checks the 2FA continuation mirrors password login.
func TestSmsLogin2FA(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().SetEmail("t@example.com").SetNick("t").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).
SetPhone("+8613912345678").SetTwoFactorSecret("JBSWY3DPEHPK3PXP").SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := smsDep(t, client, kv, enabledSmsProvider(group.ID))
require.NoError(t, kv.Set(smsCodeKey("login", "+8613912345678"), "123456", smsCodeTTLSeconds))
got, session, err := (&SmsLoginService{Phone: "+8613912345678", Code: "123456"}).Login(smsCtx(dep, nil))
require.NoError(t, err)
require.Equal(t, u.ID, got.ID)
require.NotEmpty(t, session)
// The session ticket maps back to the user for the 2FA endpoint.
raw, ok := kv.Get("user_2fa_" + session)
require.True(t, ok)
require.Equal(t, u.ID, raw.(int))
}
func TestSmsReset(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().SetEmail("r@example.com").SetNick("r").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).
SetPhone("+8613912345678").SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := smsDep(t, client, kv, enabledSmsProvider(group.ID))
// Unbound phone → not found.
_, err := (&SmsResetService{Phone: "+8610000000099", Code: "123456", Password: "newpass1"}).Reset(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeUserNotFound)
// Wrong code → rejected, password untouched.
require.NoError(t, kv.Set(smsCodeKey("reset", "+8613912345678"), "123456", smsCodeTTLSeconds))
_, err = (&SmsResetService{Phone: "+8613912345678", Code: "000000", Password: "newpass1"}).Reset(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeSmsCodeErr)
// Correct code → password updated; old password no longer matches.
res, err := (&SmsResetService{Phone: "+8613912345678", Code: "123456", Password: "newpass1"}).Reset(smsCtx(dep, nil))
require.NoError(t, err)
require.NotNil(t, res)
require.NoError(t, inventory.CheckPassword(client.User.GetX(ctx, u.ID), "newpass1"))
}
func TestSmsBindUnbind(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().SetEmail("a@example.com").SetNick("a").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).SaveX(ctx)
other := client.User.Create().SetEmail("o@example.com").SetNick("o").SetGroup(group).
SetStatus(user.StatusActive).SetSettings(&types.UserSetting{}).
SetPhone("+8613912345678").SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := smsDep(t, client, kv, enabledSmsProvider(group.ID))
// Binding a phone already claimed by another account is rejected at the
// uniqueness constraint even if a code was issued.
require.NoError(t, kv.Set(smsCodeKey("bind", "+8613912345678"), "123456", smsCodeTTLSeconds))
err := (&SmsBindService{Phone: "+8613912345678", Code: "123456"}).Bind(smsCtx(dep, u))
require.Error(t, err)
require.Nil(t, client.User.GetX(ctx, u.ID).Phone)
// Successful bind.
require.NoError(t, kv.Set(smsCodeKey("bind", "+15550077"), "999999", smsCodeTTLSeconds))
require.NoError(t, (&SmsBindService{Phone: "+15550077", Code: "999999"}).Bind(smsCtx(dep, u)))
require.Equal(t, "+15550077", *client.User.GetX(ctx, u.ID).Phone)
// Unbind clears the phone.
require.NoError(t, (&SmsUnbindService{}).Unbind(smsCtx(dep, u)))
require.Nil(t, client.User.GetX(ctx, u.ID).Phone)
require.Equal(t, "+8613912345678", *client.User.GetX(ctx, other.ID).Phone)
// Anonymous bind is rejected.
err = (&SmsBindService{Phone: "+15550077", Code: "999999"}).Bind(smsCtx(dep, nil))
requireAppCode(t, err, serializer.CodeCheckLogin)
}
func requireAppCode(t *testing.T, err error, code int) {
t.Helper()
require.Error(t, err)
var ae serializer.AppError
require.ErrorAs(t, err, &ae)
require.Equal(t, code, ae.Code)
}
Loading…
Cancel
Save