"frontend/src/api/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "53ad1645cf2e246204100ad77261303471757ecb"
Commit 1245f07a authored by shaw's avatar shaw
Browse files

feat(auth): 实现 TOTP 双因素认证功能

新增功能:
- 支持 Google Authenticator 等应用进行 TOTP 二次验证
- 用户可在个人设置中启用/禁用 2FA
- 登录时支持 TOTP 验证流程
- 管理后台可全局开关 TOTP 功能

安全增强:
- TOTP 密钥使用 AES-256-GCM 加密存储
- 添加 TOTP_ENCRYPTION_KEY 配置项,必须手动配置才能启用功能
- 防止服务重启导致加密密钥变更使用户无法登录
- 验证失败次数限制,防止暴力破解

配置说明:
- Docker 部署:在 .env 中设置 TOTP_ENCRYPTION_KEY
- 非 Docker 部署:在 config.yaml 中设置 totp.encryption_key
- 生成密钥命令:openssl rand -hex 32
parent 74e05b83
...@@ -63,7 +63,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { ...@@ -63,7 +63,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService)
userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator) userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService) secretEncryptor, err := repository.NewAESEncryptor(configConfig)
if err != nil {
return nil, err
}
totpCache := repository.NewTotpCache(redisClient)
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, totpService)
userHandler := handler.NewUserHandler(userService) userHandler := handler.NewUserHandler(userService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db) usageLogRepository := repository.NewUsageLogRepository(client, db)
...@@ -165,7 +171,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { ...@@ -165,7 +171,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, configConfig) gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, configConfig)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, configConfig) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler) totpHandler := handler.NewTotpHandler(totpService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
......
...@@ -610,6 +610,9 @@ var ( ...@@ -610,6 +610,9 @@ var (
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "username", Type: field.TypeString, Size: 100, Default: ""}, {Name: "username", Type: field.TypeString, Size: 100, Default: ""},
{Name: "notes", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}}, {Name: "notes", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
{Name: "totp_secret_encrypted", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "totp_enabled", Type: field.TypeBool, Default: false},
{Name: "totp_enabled_at", Type: field.TypeTime, Nullable: true},
} }
// UsersTable holds the schema information for the "users" table. // UsersTable holds the schema information for the "users" table.
UsersTable = &schema.Table{ UsersTable = &schema.Table{
......
...@@ -14360,6 +14360,9 @@ type UserMutation struct { ...@@ -14360,6 +14360,9 @@ type UserMutation struct {
status *string status *string
username *string username *string
notes *string notes *string
totp_secret_encrypted *string
totp_enabled *bool
totp_enabled_at *time.Time
clearedFields map[string]struct{} clearedFields map[string]struct{}
api_keys map[int64]struct{} api_keys map[int64]struct{}
removedapi_keys map[int64]struct{} removedapi_keys map[int64]struct{}
...@@ -14937,6 +14940,140 @@ func (m *UserMutation) ResetNotes() { ...@@ -14937,6 +14940,140 @@ func (m *UserMutation) ResetNotes() {
m.notes = nil m.notes = nil
} }
   
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (m *UserMutation) SetTotpSecretEncrypted(s string) {
m.totp_secret_encrypted = &s
}
// TotpSecretEncrypted returns the value of the "totp_secret_encrypted" field in the mutation.
func (m *UserMutation) TotpSecretEncrypted() (r string, exists bool) {
v := m.totp_secret_encrypted
if v == nil {
return
}
return *v, true
}
// OldTotpSecretEncrypted returns the old "totp_secret_encrypted" 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) OldTotpSecretEncrypted(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTotpSecretEncrypted is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTotpSecretEncrypted requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTotpSecretEncrypted: %w", err)
}
return oldValue.TotpSecretEncrypted, nil
}
// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field.
func (m *UserMutation) ClearTotpSecretEncrypted() {
m.totp_secret_encrypted = nil
m.clearedFields[user.FieldTotpSecretEncrypted] = struct{}{}
}
// TotpSecretEncryptedCleared returns if the "totp_secret_encrypted" field was cleared in this mutation.
func (m *UserMutation) TotpSecretEncryptedCleared() bool {
_, ok := m.clearedFields[user.FieldTotpSecretEncrypted]
return ok
}
// ResetTotpSecretEncrypted resets all changes to the "totp_secret_encrypted" field.
func (m *UserMutation) ResetTotpSecretEncrypted() {
m.totp_secret_encrypted = nil
delete(m.clearedFields, user.FieldTotpSecretEncrypted)
}
// SetTotpEnabled sets the "totp_enabled" field.
func (m *UserMutation) SetTotpEnabled(b bool) {
m.totp_enabled = &b
}
// TotpEnabled returns the value of the "totp_enabled" field in the mutation.
func (m *UserMutation) TotpEnabled() (r bool, exists bool) {
v := m.totp_enabled
if v == nil {
return
}
return *v, true
}
// OldTotpEnabled returns the old "totp_enabled" 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) OldTotpEnabled(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTotpEnabled is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTotpEnabled requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTotpEnabled: %w", err)
}
return oldValue.TotpEnabled, nil
}
// ResetTotpEnabled resets all changes to the "totp_enabled" field.
func (m *UserMutation) ResetTotpEnabled() {
m.totp_enabled = nil
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (m *UserMutation) SetTotpEnabledAt(t time.Time) {
m.totp_enabled_at = &t
}
// TotpEnabledAt returns the value of the "totp_enabled_at" field in the mutation.
func (m *UserMutation) TotpEnabledAt() (r time.Time, exists bool) {
v := m.totp_enabled_at
if v == nil {
return
}
return *v, true
}
// OldTotpEnabledAt returns the old "totp_enabled_at" 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) OldTotpEnabledAt(ctx context.Context) (v *time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTotpEnabledAt is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTotpEnabledAt requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTotpEnabledAt: %w", err)
}
return oldValue.TotpEnabledAt, nil
}
// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field.
func (m *UserMutation) ClearTotpEnabledAt() {
m.totp_enabled_at = nil
m.clearedFields[user.FieldTotpEnabledAt] = struct{}{}
}
// TotpEnabledAtCleared returns if the "totp_enabled_at" field was cleared in this mutation.
func (m *UserMutation) TotpEnabledAtCleared() bool {
_, ok := m.clearedFields[user.FieldTotpEnabledAt]
return ok
}
// ResetTotpEnabledAt resets all changes to the "totp_enabled_at" field.
func (m *UserMutation) ResetTotpEnabledAt() {
m.totp_enabled_at = nil
delete(m.clearedFields, user.FieldTotpEnabledAt)
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
func (m *UserMutation) AddAPIKeyIDs(ids ...int64) { func (m *UserMutation) AddAPIKeyIDs(ids ...int64) {
if m.api_keys == nil { if m.api_keys == nil {
...@@ -15403,7 +15540,7 @@ func (m *UserMutation) Type() string { ...@@ -15403,7 +15540,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, 11) fields := make([]string, 0, 14)
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt) fields = append(fields, user.FieldCreatedAt)
} }
...@@ -15437,6 +15574,15 @@ func (m *UserMutation) Fields() []string { ...@@ -15437,6 +15574,15 @@ func (m *UserMutation) Fields() []string {
if m.notes != nil { if m.notes != nil {
fields = append(fields, user.FieldNotes) fields = append(fields, user.FieldNotes)
} }
if m.totp_secret_encrypted != nil {
fields = append(fields, user.FieldTotpSecretEncrypted)
}
if m.totp_enabled != nil {
fields = append(fields, user.FieldTotpEnabled)
}
if m.totp_enabled_at != nil {
fields = append(fields, user.FieldTotpEnabledAt)
}
return fields return fields
} }
   
...@@ -15467,6 +15613,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { ...@@ -15467,6 +15613,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.Username() return m.Username()
case user.FieldNotes: case user.FieldNotes:
return m.Notes() return m.Notes()
case user.FieldTotpSecretEncrypted:
return m.TotpSecretEncrypted()
case user.FieldTotpEnabled:
return m.TotpEnabled()
case user.FieldTotpEnabledAt:
return m.TotpEnabledAt()
} }
return nil, false return nil, false
} }
...@@ -15498,6 +15650,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er ...@@ -15498,6 +15650,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldUsername(ctx) return m.OldUsername(ctx)
case user.FieldNotes: case user.FieldNotes:
return m.OldNotes(ctx) return m.OldNotes(ctx)
case user.FieldTotpSecretEncrypted:
return m.OldTotpSecretEncrypted(ctx)
case user.FieldTotpEnabled:
return m.OldTotpEnabled(ctx)
case user.FieldTotpEnabledAt:
return m.OldTotpEnabledAt(ctx)
} }
return nil, fmt.Errorf("unknown User field %s", name) return nil, fmt.Errorf("unknown User field %s", name)
} }
...@@ -15584,6 +15742,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { ...@@ -15584,6 +15742,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
} }
m.SetNotes(v) m.SetNotes(v)
return nil return nil
case user.FieldTotpSecretEncrypted:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTotpSecretEncrypted(v)
return nil
case user.FieldTotpEnabled:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTotpEnabled(v)
return nil
case user.FieldTotpEnabledAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTotpEnabledAt(v)
return nil
} }
return fmt.Errorf("unknown User field %s", name) return fmt.Errorf("unknown User field %s", name)
} }
...@@ -15644,6 +15823,12 @@ func (m *UserMutation) ClearedFields() []string { ...@@ -15644,6 +15823,12 @@ 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.FieldTotpSecretEncrypted) {
fields = append(fields, user.FieldTotpSecretEncrypted)
}
if m.FieldCleared(user.FieldTotpEnabledAt) {
fields = append(fields, user.FieldTotpEnabledAt)
}
return fields return fields
} }
   
...@@ -15661,6 +15846,12 @@ func (m *UserMutation) ClearField(name string) error { ...@@ -15661,6 +15846,12 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldDeletedAt: case user.FieldDeletedAt:
m.ClearDeletedAt() m.ClearDeletedAt()
return nil return nil
case user.FieldTotpSecretEncrypted:
m.ClearTotpSecretEncrypted()
return nil
case user.FieldTotpEnabledAt:
m.ClearTotpEnabledAt()
return nil
} }
return fmt.Errorf("unknown User nullable field %s", name) return fmt.Errorf("unknown User nullable field %s", name)
} }
...@@ -15702,6 +15893,15 @@ func (m *UserMutation) ResetField(name string) error { ...@@ -15702,6 +15893,15 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldNotes: case user.FieldNotes:
m.ResetNotes() m.ResetNotes()
return nil return nil
case user.FieldTotpSecretEncrypted:
m.ResetTotpSecretEncrypted()
return nil
case user.FieldTotpEnabled:
m.ResetTotpEnabled()
return nil
case user.FieldTotpEnabledAt:
m.ResetTotpEnabledAt()
return nil
} }
return fmt.Errorf("unknown User field %s", name) return fmt.Errorf("unknown User field %s", name)
} }
......
...@@ -736,6 +736,10 @@ func init() { ...@@ -736,6 +736,10 @@ func init() {
userDescNotes := userFields[7].Descriptor() userDescNotes := userFields[7].Descriptor()
// user.DefaultNotes holds the default value on creation for the notes field. // user.DefaultNotes holds the default value on creation for the notes field.
user.DefaultNotes = userDescNotes.Default.(string) user.DefaultNotes = userDescNotes.Default.(string)
// userDescTotpEnabled is the schema descriptor for totp_enabled field.
userDescTotpEnabled := userFields[9].Descriptor()
// user.DefaultTotpEnabled holds the default value on creation for the totp_enabled field.
user.DefaultTotpEnabled = userDescTotpEnabled.Default.(bool)
userallowedgroupFields := schema.UserAllowedGroup{}.Fields() userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
_ = userallowedgroupFields _ = userallowedgroupFields
// userallowedgroupDescCreatedAt is the schema descriptor for created_at field. // userallowedgroupDescCreatedAt is the schema descriptor for created_at field.
......
...@@ -61,6 +61,17 @@ func (User) Fields() []ent.Field { ...@@ -61,6 +61,17 @@ func (User) Fields() []ent.Field {
field.String("notes"). field.String("notes").
SchemaType(map[string]string{dialect.Postgres: "text"}). SchemaType(map[string]string{dialect.Postgres: "text"}).
Default(""), Default(""),
// TOTP 双因素认证字段
field.String("totp_secret_encrypted").
SchemaType(map[string]string{dialect.Postgres: "text"}).
Optional().
Nillable(),
field.Bool("totp_enabled").
Default(false),
field.Time("totp_enabled_at").
Optional().
Nillable(),
} }
} }
......
...@@ -39,6 +39,12 @@ type User struct { ...@@ -39,6 +39,12 @@ type User struct {
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
// Notes holds the value of the "notes" field. // Notes holds the value of the "notes" field.
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
// TotpSecretEncrypted holds the value of the "totp_secret_encrypted" field.
TotpSecretEncrypted *string `json:"totp_secret_encrypted,omitempty"`
// TotpEnabled holds the value of the "totp_enabled" field.
TotpEnabled bool `json:"totp_enabled,omitempty"`
// TotpEnabledAt holds the value of the "totp_enabled_at" field.
TotpEnabledAt *time.Time `json:"totp_enabled_at,omitempty"`
// Edges holds the relations/edges for other nodes in the graph. // Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the UserQuery when eager-loading is set. // The values are being populated by the UserQuery when eager-loading is set.
Edges UserEdges `json:"edges"` Edges UserEdges `json:"edges"`
...@@ -156,13 +162,15 @@ func (*User) scanValues(columns []string) ([]any, error) { ...@@ -156,13 +162,15 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns)) values := make([]any, len(columns))
for i := range columns { for i := range columns {
switch columns[i] { switch columns[i] {
case user.FieldTotpEnabled:
values[i] = new(sql.NullBool)
case user.FieldBalance: case user.FieldBalance:
values[i] = new(sql.NullFloat64) values[i] = new(sql.NullFloat64)
case user.FieldID, user.FieldConcurrency: case user.FieldID, user.FieldConcurrency:
values[i] = new(sql.NullInt64) values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes: case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted:
values[i] = new(sql.NullString) values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt: case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldTotpEnabledAt:
values[i] = new(sql.NullTime) values[i] = new(sql.NullTime)
default: default:
values[i] = new(sql.UnknownType) values[i] = new(sql.UnknownType)
...@@ -252,6 +260,26 @@ func (_m *User) assignValues(columns []string, values []any) error { ...@@ -252,6 +260,26 @@ func (_m *User) assignValues(columns []string, values []any) error {
} else if value.Valid { } else if value.Valid {
_m.Notes = value.String _m.Notes = value.String
} }
case user.FieldTotpSecretEncrypted:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field totp_secret_encrypted", values[i])
} else if value.Valid {
_m.TotpSecretEncrypted = new(string)
*_m.TotpSecretEncrypted = value.String
}
case user.FieldTotpEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field totp_enabled", values[i])
} else if value.Valid {
_m.TotpEnabled = value.Bool
}
case user.FieldTotpEnabledAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field totp_enabled_at", values[i])
} else if value.Valid {
_m.TotpEnabledAt = new(time.Time)
*_m.TotpEnabledAt = value.Time
}
default: default:
_m.selectValues.Set(columns[i], values[i]) _m.selectValues.Set(columns[i], values[i])
} }
...@@ -367,6 +395,19 @@ func (_m *User) String() string { ...@@ -367,6 +395,19 @@ func (_m *User) String() string {
builder.WriteString(", ") builder.WriteString(", ")
builder.WriteString("notes=") builder.WriteString("notes=")
builder.WriteString(_m.Notes) builder.WriteString(_m.Notes)
builder.WriteString(", ")
if v := _m.TotpSecretEncrypted; v != nil {
builder.WriteString("totp_secret_encrypted=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("totp_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.TotpEnabled))
builder.WriteString(", ")
if v := _m.TotpEnabledAt; v != nil {
builder.WriteString("totp_enabled_at=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteByte(')') builder.WriteByte(')')
return builder.String() return builder.String()
} }
......
...@@ -37,6 +37,12 @@ const ( ...@@ -37,6 +37,12 @@ const (
FieldUsername = "username" FieldUsername = "username"
// FieldNotes holds the string denoting the notes field in the database. // FieldNotes holds the string denoting the notes field in the database.
FieldNotes = "notes" FieldNotes = "notes"
// FieldTotpSecretEncrypted holds the string denoting the totp_secret_encrypted field in the database.
FieldTotpSecretEncrypted = "totp_secret_encrypted"
// FieldTotpEnabled holds the string denoting the totp_enabled field in the database.
FieldTotpEnabled = "totp_enabled"
// FieldTotpEnabledAt holds the string denoting the totp_enabled_at field in the database.
FieldTotpEnabledAt = "totp_enabled_at"
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
EdgeAPIKeys = "api_keys" EdgeAPIKeys = "api_keys"
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
...@@ -134,6 +140,9 @@ var Columns = []string{ ...@@ -134,6 +140,9 @@ var Columns = []string{
FieldStatus, FieldStatus,
FieldUsername, FieldUsername,
FieldNotes, FieldNotes,
FieldTotpSecretEncrypted,
FieldTotpEnabled,
FieldTotpEnabledAt,
} }
var ( var (
...@@ -188,6 +197,8 @@ var ( ...@@ -188,6 +197,8 @@ var (
UsernameValidator func(string) error UsernameValidator func(string) error
// DefaultNotes holds the default value on creation for the "notes" field. // DefaultNotes holds the default value on creation for the "notes" field.
DefaultNotes string DefaultNotes string
// DefaultTotpEnabled holds the default value on creation for the "totp_enabled" field.
DefaultTotpEnabled bool
) )
// OrderOption defines the ordering options for the User queries. // OrderOption defines the ordering options for the User queries.
...@@ -253,6 +264,21 @@ func ByNotes(opts ...sql.OrderTermOption) OrderOption { ...@@ -253,6 +264,21 @@ func ByNotes(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldNotes, opts...).ToFunc() return sql.OrderByField(FieldNotes, opts...).ToFunc()
} }
// ByTotpSecretEncrypted orders the results by the totp_secret_encrypted field.
func ByTotpSecretEncrypted(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTotpSecretEncrypted, opts...).ToFunc()
}
// ByTotpEnabled orders the results by the totp_enabled field.
func ByTotpEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTotpEnabled, opts...).ToFunc()
}
// ByTotpEnabledAt orders the results by the totp_enabled_at field.
func ByTotpEnabledAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTotpEnabledAt, opts...).ToFunc()
}
// ByAPIKeysCount orders the results by api_keys count. // ByAPIKeysCount orders the results by api_keys count.
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) { return func(s *sql.Selector) {
......
...@@ -110,6 +110,21 @@ func Notes(v string) predicate.User { ...@@ -110,6 +110,21 @@ func Notes(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldNotes, v)) return predicate.User(sql.FieldEQ(FieldNotes, v))
} }
// TotpSecretEncrypted applies equality check predicate on the "totp_secret_encrypted" field. It's identical to TotpSecretEncryptedEQ.
func TotpSecretEncrypted(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldTotpSecretEncrypted, v))
}
// TotpEnabled applies equality check predicate on the "totp_enabled" field. It's identical to TotpEnabledEQ.
func TotpEnabled(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldTotpEnabled, v))
}
// TotpEnabledAt applies equality check predicate on the "totp_enabled_at" field. It's identical to TotpEnabledAtEQ.
func TotpEnabledAt(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldTotpEnabledAt, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field. // CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.User { func CreatedAtEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldCreatedAt, v)) return predicate.User(sql.FieldEQ(FieldCreatedAt, v))
...@@ -710,6 +725,141 @@ func NotesContainsFold(v string) predicate.User { ...@@ -710,6 +725,141 @@ func NotesContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldNotes, v)) return predicate.User(sql.FieldContainsFold(FieldNotes, v))
} }
// TotpSecretEncryptedEQ applies the EQ predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedNEQ applies the NEQ predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedNEQ(v string) predicate.User {
return predicate.User(sql.FieldNEQ(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedIn applies the In predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedIn(vs ...string) predicate.User {
return predicate.User(sql.FieldIn(FieldTotpSecretEncrypted, vs...))
}
// TotpSecretEncryptedNotIn applies the NotIn predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedNotIn(vs ...string) predicate.User {
return predicate.User(sql.FieldNotIn(FieldTotpSecretEncrypted, vs...))
}
// TotpSecretEncryptedGT applies the GT predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedGT(v string) predicate.User {
return predicate.User(sql.FieldGT(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedGTE applies the GTE predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedGTE(v string) predicate.User {
return predicate.User(sql.FieldGTE(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedLT applies the LT predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedLT(v string) predicate.User {
return predicate.User(sql.FieldLT(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedLTE applies the LTE predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedLTE(v string) predicate.User {
return predicate.User(sql.FieldLTE(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedContains applies the Contains predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedContains(v string) predicate.User {
return predicate.User(sql.FieldContains(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedHasPrefix applies the HasPrefix predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedHasPrefix(v string) predicate.User {
return predicate.User(sql.FieldHasPrefix(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedHasSuffix applies the HasSuffix predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedHasSuffix(v string) predicate.User {
return predicate.User(sql.FieldHasSuffix(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedIsNil applies the IsNil predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldTotpSecretEncrypted))
}
// TotpSecretEncryptedNotNil applies the NotNil predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldTotpSecretEncrypted))
}
// TotpSecretEncryptedEqualFold applies the EqualFold predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedEqualFold(v string) predicate.User {
return predicate.User(sql.FieldEqualFold(FieldTotpSecretEncrypted, v))
}
// TotpSecretEncryptedContainsFold applies the ContainsFold predicate on the "totp_secret_encrypted" field.
func TotpSecretEncryptedContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldTotpSecretEncrypted, v))
}
// TotpEnabledEQ applies the EQ predicate on the "totp_enabled" field.
func TotpEnabledEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldTotpEnabled, v))
}
// TotpEnabledNEQ applies the NEQ predicate on the "totp_enabled" field.
func TotpEnabledNEQ(v bool) predicate.User {
return predicate.User(sql.FieldNEQ(FieldTotpEnabled, v))
}
// TotpEnabledAtEQ applies the EQ predicate on the "totp_enabled_at" field.
func TotpEnabledAtEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldTotpEnabledAt, v))
}
// TotpEnabledAtNEQ applies the NEQ predicate on the "totp_enabled_at" field.
func TotpEnabledAtNEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldNEQ(FieldTotpEnabledAt, v))
}
// TotpEnabledAtIn applies the In predicate on the "totp_enabled_at" field.
func TotpEnabledAtIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldIn(FieldTotpEnabledAt, vs...))
}
// TotpEnabledAtNotIn applies the NotIn predicate on the "totp_enabled_at" field.
func TotpEnabledAtNotIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldNotIn(FieldTotpEnabledAt, vs...))
}
// TotpEnabledAtGT applies the GT predicate on the "totp_enabled_at" field.
func TotpEnabledAtGT(v time.Time) predicate.User {
return predicate.User(sql.FieldGT(FieldTotpEnabledAt, v))
}
// TotpEnabledAtGTE applies the GTE predicate on the "totp_enabled_at" field.
func TotpEnabledAtGTE(v time.Time) predicate.User {
return predicate.User(sql.FieldGTE(FieldTotpEnabledAt, v))
}
// TotpEnabledAtLT applies the LT predicate on the "totp_enabled_at" field.
func TotpEnabledAtLT(v time.Time) predicate.User {
return predicate.User(sql.FieldLT(FieldTotpEnabledAt, v))
}
// TotpEnabledAtLTE applies the LTE predicate on the "totp_enabled_at" field.
func TotpEnabledAtLTE(v time.Time) predicate.User {
return predicate.User(sql.FieldLTE(FieldTotpEnabledAt, v))
}
// TotpEnabledAtIsNil applies the IsNil predicate on the "totp_enabled_at" field.
func TotpEnabledAtIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldTotpEnabledAt))
}
// TotpEnabledAtNotNil applies the NotNil predicate on the "totp_enabled_at" field.
func TotpEnabledAtNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldTotpEnabledAt))
}
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
func HasAPIKeys() predicate.User { func HasAPIKeys() predicate.User {
return predicate.User(func(s *sql.Selector) { return predicate.User(func(s *sql.Selector) {
......
...@@ -167,6 +167,48 @@ func (_c *UserCreate) SetNillableNotes(v *string) *UserCreate { ...@@ -167,6 +167,48 @@ func (_c *UserCreate) SetNillableNotes(v *string) *UserCreate {
return _c return _c
} }
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (_c *UserCreate) SetTotpSecretEncrypted(v string) *UserCreate {
_c.mutation.SetTotpSecretEncrypted(v)
return _c
}
// SetNillableTotpSecretEncrypted sets the "totp_secret_encrypted" field if the given value is not nil.
func (_c *UserCreate) SetNillableTotpSecretEncrypted(v *string) *UserCreate {
if v != nil {
_c.SetTotpSecretEncrypted(*v)
}
return _c
}
// SetTotpEnabled sets the "totp_enabled" field.
func (_c *UserCreate) SetTotpEnabled(v bool) *UserCreate {
_c.mutation.SetTotpEnabled(v)
return _c
}
// SetNillableTotpEnabled sets the "totp_enabled" field if the given value is not nil.
func (_c *UserCreate) SetNillableTotpEnabled(v *bool) *UserCreate {
if v != nil {
_c.SetTotpEnabled(*v)
}
return _c
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (_c *UserCreate) SetTotpEnabledAt(v time.Time) *UserCreate {
_c.mutation.SetTotpEnabledAt(v)
return _c
}
// SetNillableTotpEnabledAt sets the "totp_enabled_at" field if the given value is not nil.
func (_c *UserCreate) SetNillableTotpEnabledAt(v *time.Time) *UserCreate {
if v != nil {
_c.SetTotpEnabledAt(*v)
}
return _c
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate { func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate {
_c.mutation.AddAPIKeyIDs(ids...) _c.mutation.AddAPIKeyIDs(ids...)
...@@ -362,6 +404,10 @@ func (_c *UserCreate) defaults() error { ...@@ -362,6 +404,10 @@ func (_c *UserCreate) defaults() error {
v := user.DefaultNotes v := user.DefaultNotes
_c.mutation.SetNotes(v) _c.mutation.SetNotes(v)
} }
if _, ok := _c.mutation.TotpEnabled(); !ok {
v := user.DefaultTotpEnabled
_c.mutation.SetTotpEnabled(v)
}
return nil return nil
} }
...@@ -422,6 +468,9 @@ func (_c *UserCreate) check() error { ...@@ -422,6 +468,9 @@ func (_c *UserCreate) check() error {
if _, ok := _c.mutation.Notes(); !ok { if _, ok := _c.mutation.Notes(); !ok {
return &ValidationError{Name: "notes", err: errors.New(`ent: missing required field "User.notes"`)} return &ValidationError{Name: "notes", err: errors.New(`ent: missing required field "User.notes"`)}
} }
if _, ok := _c.mutation.TotpEnabled(); !ok {
return &ValidationError{Name: "totp_enabled", err: errors.New(`ent: missing required field "User.totp_enabled"`)}
}
return nil return nil
} }
...@@ -493,6 +542,18 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { ...@@ -493,6 +542,18 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldNotes, field.TypeString, value) _spec.SetField(user.FieldNotes, field.TypeString, value)
_node.Notes = value _node.Notes = value
} }
if value, ok := _c.mutation.TotpSecretEncrypted(); ok {
_spec.SetField(user.FieldTotpSecretEncrypted, field.TypeString, value)
_node.TotpSecretEncrypted = &value
}
if value, ok := _c.mutation.TotpEnabled(); ok {
_spec.SetField(user.FieldTotpEnabled, field.TypeBool, value)
_node.TotpEnabled = value
}
if value, ok := _c.mutation.TotpEnabledAt(); ok {
_spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value)
_node.TotpEnabledAt = &value
}
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,
...@@ -815,6 +876,54 @@ func (u *UserUpsert) UpdateNotes() *UserUpsert { ...@@ -815,6 +876,54 @@ func (u *UserUpsert) UpdateNotes() *UserUpsert {
return u return u
} }
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (u *UserUpsert) SetTotpSecretEncrypted(v string) *UserUpsert {
u.Set(user.FieldTotpSecretEncrypted, v)
return u
}
// UpdateTotpSecretEncrypted sets the "totp_secret_encrypted" field to the value that was provided on create.
func (u *UserUpsert) UpdateTotpSecretEncrypted() *UserUpsert {
u.SetExcluded(user.FieldTotpSecretEncrypted)
return u
}
// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field.
func (u *UserUpsert) ClearTotpSecretEncrypted() *UserUpsert {
u.SetNull(user.FieldTotpSecretEncrypted)
return u
}
// SetTotpEnabled sets the "totp_enabled" field.
func (u *UserUpsert) SetTotpEnabled(v bool) *UserUpsert {
u.Set(user.FieldTotpEnabled, v)
return u
}
// UpdateTotpEnabled sets the "totp_enabled" field to the value that was provided on create.
func (u *UserUpsert) UpdateTotpEnabled() *UserUpsert {
u.SetExcluded(user.FieldTotpEnabled)
return u
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (u *UserUpsert) SetTotpEnabledAt(v time.Time) *UserUpsert {
u.Set(user.FieldTotpEnabledAt, v)
return u
}
// UpdateTotpEnabledAt sets the "totp_enabled_at" field to the value that was provided on create.
func (u *UserUpsert) UpdateTotpEnabledAt() *UserUpsert {
u.SetExcluded(user.FieldTotpEnabledAt)
return u
}
// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field.
func (u *UserUpsert) ClearTotpEnabledAt() *UserUpsert {
u.SetNull(user.FieldTotpEnabledAt)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create. // UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using: // Using this option is equivalent to using:
// //
...@@ -1021,6 +1130,62 @@ func (u *UserUpsertOne) UpdateNotes() *UserUpsertOne { ...@@ -1021,6 +1130,62 @@ func (u *UserUpsertOne) UpdateNotes() *UserUpsertOne {
}) })
} }
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (u *UserUpsertOne) SetTotpSecretEncrypted(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetTotpSecretEncrypted(v)
})
}
// UpdateTotpSecretEncrypted sets the "totp_secret_encrypted" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateTotpSecretEncrypted() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateTotpSecretEncrypted()
})
}
// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field.
func (u *UserUpsertOne) ClearTotpSecretEncrypted() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearTotpSecretEncrypted()
})
}
// SetTotpEnabled sets the "totp_enabled" field.
func (u *UserUpsertOne) SetTotpEnabled(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetTotpEnabled(v)
})
}
// UpdateTotpEnabled sets the "totp_enabled" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateTotpEnabled() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateTotpEnabled()
})
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (u *UserUpsertOne) SetTotpEnabledAt(v time.Time) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetTotpEnabledAt(v)
})
}
// UpdateTotpEnabledAt sets the "totp_enabled_at" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateTotpEnabledAt() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateTotpEnabledAt()
})
}
// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field.
func (u *UserUpsertOne) ClearTotpEnabledAt() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearTotpEnabledAt()
})
}
// Exec executes the query. // Exec executes the query.
func (u *UserUpsertOne) Exec(ctx context.Context) error { func (u *UserUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 { if len(u.create.conflict) == 0 {
...@@ -1393,6 +1558,62 @@ func (u *UserUpsertBulk) UpdateNotes() *UserUpsertBulk { ...@@ -1393,6 +1558,62 @@ func (u *UserUpsertBulk) UpdateNotes() *UserUpsertBulk {
}) })
} }
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (u *UserUpsertBulk) SetTotpSecretEncrypted(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetTotpSecretEncrypted(v)
})
}
// UpdateTotpSecretEncrypted sets the "totp_secret_encrypted" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateTotpSecretEncrypted() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateTotpSecretEncrypted()
})
}
// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field.
func (u *UserUpsertBulk) ClearTotpSecretEncrypted() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearTotpSecretEncrypted()
})
}
// SetTotpEnabled sets the "totp_enabled" field.
func (u *UserUpsertBulk) SetTotpEnabled(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetTotpEnabled(v)
})
}
// UpdateTotpEnabled sets the "totp_enabled" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateTotpEnabled() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateTotpEnabled()
})
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (u *UserUpsertBulk) SetTotpEnabledAt(v time.Time) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetTotpEnabledAt(v)
})
}
// UpdateTotpEnabledAt sets the "totp_enabled_at" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateTotpEnabledAt() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateTotpEnabledAt()
})
}
// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field.
func (u *UserUpsertBulk) ClearTotpEnabledAt() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearTotpEnabledAt()
})
}
// Exec executes the query. // Exec executes the query.
func (u *UserUpsertBulk) Exec(ctx context.Context) error { func (u *UserUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil { if u.create.err != nil {
......
...@@ -187,6 +187,60 @@ func (_u *UserUpdate) SetNillableNotes(v *string) *UserUpdate { ...@@ -187,6 +187,60 @@ func (_u *UserUpdate) SetNillableNotes(v *string) *UserUpdate {
return _u return _u
} }
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (_u *UserUpdate) SetTotpSecretEncrypted(v string) *UserUpdate {
_u.mutation.SetTotpSecretEncrypted(v)
return _u
}
// SetNillableTotpSecretEncrypted sets the "totp_secret_encrypted" field if the given value is not nil.
func (_u *UserUpdate) SetNillableTotpSecretEncrypted(v *string) *UserUpdate {
if v != nil {
_u.SetTotpSecretEncrypted(*v)
}
return _u
}
// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field.
func (_u *UserUpdate) ClearTotpSecretEncrypted() *UserUpdate {
_u.mutation.ClearTotpSecretEncrypted()
return _u
}
// SetTotpEnabled sets the "totp_enabled" field.
func (_u *UserUpdate) SetTotpEnabled(v bool) *UserUpdate {
_u.mutation.SetTotpEnabled(v)
return _u
}
// SetNillableTotpEnabled sets the "totp_enabled" field if the given value is not nil.
func (_u *UserUpdate) SetNillableTotpEnabled(v *bool) *UserUpdate {
if v != nil {
_u.SetTotpEnabled(*v)
}
return _u
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (_u *UserUpdate) SetTotpEnabledAt(v time.Time) *UserUpdate {
_u.mutation.SetTotpEnabledAt(v)
return _u
}
// SetNillableTotpEnabledAt sets the "totp_enabled_at" field if the given value is not nil.
func (_u *UserUpdate) SetNillableTotpEnabledAt(v *time.Time) *UserUpdate {
if v != nil {
_u.SetTotpEnabledAt(*v)
}
return _u
}
// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field.
func (_u *UserUpdate) ClearTotpEnabledAt() *UserUpdate {
_u.mutation.ClearTotpEnabledAt()
return _u
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate { func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate {
_u.mutation.AddAPIKeyIDs(ids...) _u.mutation.AddAPIKeyIDs(ids...)
...@@ -603,6 +657,21 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { ...@@ -603,6 +657,21 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Notes(); ok { if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(user.FieldNotes, field.TypeString, value) _spec.SetField(user.FieldNotes, field.TypeString, value)
} }
if value, ok := _u.mutation.TotpSecretEncrypted(); ok {
_spec.SetField(user.FieldTotpSecretEncrypted, field.TypeString, value)
}
if _u.mutation.TotpSecretEncryptedCleared() {
_spec.ClearField(user.FieldTotpSecretEncrypted, field.TypeString)
}
if value, ok := _u.mutation.TotpEnabled(); ok {
_spec.SetField(user.FieldTotpEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.TotpEnabledAt(); ok {
_spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value)
}
if _u.mutation.TotpEnabledAtCleared() {
_spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime)
}
if _u.mutation.APIKeysCleared() { if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,
...@@ -1147,6 +1216,60 @@ func (_u *UserUpdateOne) SetNillableNotes(v *string) *UserUpdateOne { ...@@ -1147,6 +1216,60 @@ func (_u *UserUpdateOne) SetNillableNotes(v *string) *UserUpdateOne {
return _u return _u
} }
// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field.
func (_u *UserUpdateOne) SetTotpSecretEncrypted(v string) *UserUpdateOne {
_u.mutation.SetTotpSecretEncrypted(v)
return _u
}
// SetNillableTotpSecretEncrypted sets the "totp_secret_encrypted" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableTotpSecretEncrypted(v *string) *UserUpdateOne {
if v != nil {
_u.SetTotpSecretEncrypted(*v)
}
return _u
}
// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field.
func (_u *UserUpdateOne) ClearTotpSecretEncrypted() *UserUpdateOne {
_u.mutation.ClearTotpSecretEncrypted()
return _u
}
// SetTotpEnabled sets the "totp_enabled" field.
func (_u *UserUpdateOne) SetTotpEnabled(v bool) *UserUpdateOne {
_u.mutation.SetTotpEnabled(v)
return _u
}
// SetNillableTotpEnabled sets the "totp_enabled" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableTotpEnabled(v *bool) *UserUpdateOne {
if v != nil {
_u.SetTotpEnabled(*v)
}
return _u
}
// SetTotpEnabledAt sets the "totp_enabled_at" field.
func (_u *UserUpdateOne) SetTotpEnabledAt(v time.Time) *UserUpdateOne {
_u.mutation.SetTotpEnabledAt(v)
return _u
}
// SetNillableTotpEnabledAt sets the "totp_enabled_at" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableTotpEnabledAt(v *time.Time) *UserUpdateOne {
if v != nil {
_u.SetTotpEnabledAt(*v)
}
return _u
}
// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field.
func (_u *UserUpdateOne) ClearTotpEnabledAt() *UserUpdateOne {
_u.mutation.ClearTotpEnabledAt()
return _u
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne { func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne {
_u.mutation.AddAPIKeyIDs(ids...) _u.mutation.AddAPIKeyIDs(ids...)
...@@ -1593,6 +1716,21 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { ...@@ -1593,6 +1716,21 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if value, ok := _u.mutation.Notes(); ok { if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(user.FieldNotes, field.TypeString, value) _spec.SetField(user.FieldNotes, field.TypeString, value)
} }
if value, ok := _u.mutation.TotpSecretEncrypted(); ok {
_spec.SetField(user.FieldTotpSecretEncrypted, field.TypeString, value)
}
if _u.mutation.TotpSecretEncryptedCleared() {
_spec.ClearField(user.FieldTotpSecretEncrypted, field.TypeString)
}
if value, ok := _u.mutation.TotpEnabled(); ok {
_spec.SetField(user.FieldTotpEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.TotpEnabledAt(); ok {
_spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value)
}
if _u.mutation.TotpEnabledAtCleared() {
_spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime)
}
if _u.mutation.APIKeysCleared() { if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,
......
...@@ -37,6 +37,7 @@ require ( ...@@ -37,6 +37,7 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
...@@ -106,6 +107,7 @@ require ( ...@@ -106,6 +107,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect github.com/refraction-networking/utls v1.8.1 // indirect
......
...@@ -20,6 +20,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew ...@@ -20,6 +20,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
...@@ -217,6 +219,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI ...@@ -217,6 +219,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
......
...@@ -47,6 +47,7 @@ type Config struct { ...@@ -47,6 +47,7 @@ type Config struct {
Redis RedisConfig `mapstructure:"redis"` Redis RedisConfig `mapstructure:"redis"`
Ops OpsConfig `mapstructure:"ops"` Ops OpsConfig `mapstructure:"ops"`
JWT JWTConfig `mapstructure:"jwt"` JWT JWTConfig `mapstructure:"jwt"`
Totp TotpConfig `mapstructure:"totp"`
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
Default DefaultConfig `mapstructure:"default"` Default DefaultConfig `mapstructure:"default"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"` RateLimit RateLimitConfig `mapstructure:"rate_limit"`
...@@ -466,6 +467,16 @@ type JWTConfig struct { ...@@ -466,6 +467,16 @@ type JWTConfig struct {
ExpireHour int `mapstructure:"expire_hour"` ExpireHour int `mapstructure:"expire_hour"`
} }
// TotpConfig TOTP 双因素认证配置
type TotpConfig struct {
// EncryptionKey 用于加密 TOTP 密钥的 AES-256 密钥(32 字节 hex 编码)
// 如果为空,将自动生成一个随机密钥(仅适用于开发环境)
EncryptionKey string `mapstructure:"encryption_key"`
// EncryptionKeyConfigured 标记加密密钥是否为手动配置(非自动生成)
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
EncryptionKeyConfigured bool `mapstructure:"-"`
}
type TurnstileConfig struct { type TurnstileConfig struct {
Required bool `mapstructure:"required"` Required bool `mapstructure:"required"`
} }
...@@ -626,6 +637,20 @@ func Load() (*Config, error) { ...@@ -626,6 +637,20 @@ func Load() (*Config, error) {
log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
} }
// Auto-generate TOTP encryption key if not set (32 bytes = 64 hex chars for AES-256)
cfg.Totp.EncryptionKey = strings.TrimSpace(cfg.Totp.EncryptionKey)
if cfg.Totp.EncryptionKey == "" {
key, err := generateJWTSecret(32) // Reuse the same random generation function
if err != nil {
return nil, fmt.Errorf("generate totp encryption key error: %w", err)
}
cfg.Totp.EncryptionKey = key
cfg.Totp.EncryptionKeyConfigured = false
log.Println("Warning: TOTP encryption key auto-generated. Consider setting a fixed key for production.")
} else {
cfg.Totp.EncryptionKeyConfigured = true
}
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config error: %w", err) return nil, fmt.Errorf("validate config error: %w", err)
} }
...@@ -756,6 +781,9 @@ func setDefaults() { ...@@ -756,6 +781,9 @@ func setDefaults() {
viper.SetDefault("jwt.secret", "") viper.SetDefault("jwt.secret", "")
viper.SetDefault("jwt.expire_hour", 24) viper.SetDefault("jwt.expire_hour", 24)
// TOTP
viper.SetDefault("totp.encryption_key", "")
// Default // Default
// Admin credentials are created via the setup flow (web wizard / CLI / AUTO_SETUP). // Admin credentials are created via the setup flow (web wizard / CLI / AUTO_SETUP).
// Do not ship fixed defaults here to avoid insecure "known credentials" in production. // Do not ship fixed defaults here to avoid insecure "known credentials" in production.
......
...@@ -49,6 +49,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -49,6 +49,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SMTPHost: settings.SMTPHost, SMTPHost: settings.SMTPHost,
SMTPPort: settings.SMTPPort, SMTPPort: settings.SMTPPort,
SMTPUsername: settings.SMTPUsername, SMTPUsername: settings.SMTPUsername,
...@@ -94,6 +96,7 @@ type UpdateSettingsRequest struct { ...@@ -94,6 +96,7 @@ type UpdateSettingsRequest struct {
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
// 邮件服务设置 // 邮件服务设置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
...@@ -200,6 +203,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -200,6 +203,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
} }
// TOTP 双因素认证参数验证
// 只有手动配置了加密密钥才允许启用 TOTP 功能
if req.TotpEnabled && !previousSettings.TotpEnabled {
// 尝试启用 TOTP,检查加密密钥是否已手动配置
if !h.settingService.IsTotpEncryptionKeyConfigured() {
response.BadRequest(c, "Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.")
return
}
}
// LinuxDo Connect 参数验证 // LinuxDo Connect 参数验证
if req.LinuxDoConnectEnabled { if req.LinuxDoConnectEnabled {
req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID) req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID)
...@@ -246,6 +259,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -246,6 +259,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EmailVerifyEnabled: req.EmailVerifyEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled,
PromoCodeEnabled: req.PromoCodeEnabled, PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled, PasswordResetEnabled: req.PasswordResetEnabled,
TotpEnabled: req.TotpEnabled,
SMTPHost: req.SMTPHost, SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort, SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername, SMTPUsername: req.SMTPUsername,
...@@ -322,6 +336,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -322,6 +336,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SMTPHost: updatedSettings.SMTPHost, SMTPHost: updatedSettings.SMTPHost,
SMTPPort: updatedSettings.SMTPPort, SMTPPort: updatedSettings.SMTPPort,
SMTPUsername: updatedSettings.SMTPUsername, SMTPUsername: updatedSettings.SMTPUsername,
...@@ -391,6 +407,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -391,6 +407,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.PasswordResetEnabled != after.PasswordResetEnabled { if before.PasswordResetEnabled != after.PasswordResetEnabled {
changed = append(changed, "password_reset_enabled") changed = append(changed, "password_reset_enabled")
} }
if before.TotpEnabled != after.TotpEnabled {
changed = append(changed, "totp_enabled")
}
if before.SMTPHost != after.SMTPHost { if before.SMTPHost != after.SMTPHost {
changed = append(changed, "smtp_host") changed = append(changed, "smtp_host")
} }
......
package handler package handler
import ( import (
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
...@@ -18,16 +20,18 @@ type AuthHandler struct { ...@@ -18,16 +20,18 @@ type AuthHandler struct {
userService *service.UserService userService *service.UserService
settingSvc *service.SettingService settingSvc *service.SettingService
promoService *service.PromoService promoService *service.PromoService
totpService *service.TotpService
} }
// NewAuthHandler creates a new AuthHandler // NewAuthHandler creates a new AuthHandler
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService) *AuthHandler { func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, totpService *service.TotpService) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
cfg: cfg, cfg: cfg,
authService: authService, authService: authService,
userService: userService, userService: userService,
settingSvc: settingService, settingSvc: settingService,
promoService: promoService, promoService: promoService,
totpService: totpService,
} }
} }
...@@ -144,6 +148,100 @@ func (h *AuthHandler) Login(c *gin.Context) { ...@@ -144,6 +148,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
return return
} }
// Check if TOTP 2FA is enabled for this user
if h.totpService != nil && h.settingSvc.IsTotpEnabled(c.Request.Context()) && user.TotpEnabled {
// Create a temporary login session for 2FA
tempToken, err := h.totpService.CreateLoginSession(c.Request.Context(), user.ID, user.Email)
if err != nil {
response.InternalError(c, "Failed to create 2FA session")
return
}
response.Success(c, TotpLoginResponse{
Requires2FA: true,
TempToken: tempToken,
UserEmailMasked: service.MaskEmail(user.Email),
})
return
}
response.Success(c, AuthResponse{
AccessToken: token,
TokenType: "Bearer",
User: dto.UserFromService(user),
})
}
// TotpLoginResponse represents the response when 2FA is required
type TotpLoginResponse struct {
Requires2FA bool `json:"requires_2fa"`
TempToken string `json:"temp_token,omitempty"`
UserEmailMasked string `json:"user_email_masked,omitempty"`
}
// Login2FARequest represents the 2FA login request
type Login2FARequest struct {
TempToken string `json:"temp_token" binding:"required"`
TotpCode string `json:"totp_code" binding:"required,len=6"`
}
// Login2FA completes the login with 2FA verification
// POST /api/v1/auth/login/2fa
func (h *AuthHandler) Login2FA(c *gin.Context) {
var req Login2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
slog.Debug("login_2fa_request",
"temp_token_len", len(req.TempToken),
"totp_code_len", len(req.TotpCode))
// Get the login session
session, err := h.totpService.GetLoginSession(c.Request.Context(), req.TempToken)
if err != nil || session == nil {
tokenPrefix := ""
if len(req.TempToken) >= 8 {
tokenPrefix = req.TempToken[:8]
}
slog.Debug("login_2fa_session_invalid",
"temp_token_prefix", tokenPrefix,
"error", err)
response.BadRequest(c, "Invalid or expired 2FA session")
return
}
slog.Debug("login_2fa_session_found",
"user_id", session.UserID,
"email", session.Email)
// Verify the TOTP code
if err := h.totpService.VerifyCode(c.Request.Context(), session.UserID, req.TotpCode); err != nil {
slog.Debug("login_2fa_verify_failed",
"user_id", session.UserID,
"error", err)
response.ErrorFrom(c, err)
return
}
// Delete the login session
_ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken)
// Get the user
user, err := h.userService.GetByID(c.Request.Context(), session.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Generate the JWT token
token, err := h.authService.GenerateToken(user)
if err != nil {
response.InternalError(c, "Failed to generate token")
return
}
response.Success(c, AuthResponse{ response.Success(c, AuthResponse{
AccessToken: token, AccessToken: token,
TokenType: "Bearer", TokenType: "Bearer",
......
...@@ -2,10 +2,12 @@ package dto ...@@ -2,10 +2,12 @@ package dto
// SystemSettings represents the admin settings API response payload. // SystemSettings represents the admin settings API response payload.
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"` SMTPPort int `json:"smtp_port"`
...@@ -59,6 +61,7 @@ type PublicSettings struct { ...@@ -59,6 +61,7 @@ type PublicSettings struct {
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
......
...@@ -37,6 +37,7 @@ type Handlers struct { ...@@ -37,6 +37,7 @@ type Handlers struct {
Gateway *GatewayHandler Gateway *GatewayHandler
OpenAIGateway *OpenAIGatewayHandler OpenAIGateway *OpenAIGatewayHandler
Setting *SettingHandler Setting *SettingHandler
Totp *TotpHandler
} }
// BuildInfo contains build-time information // BuildInfo contains build-time information
......
package handler
import (
"github.com/gin-gonic/gin"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// TotpHandler handles TOTP-related requests
type TotpHandler struct {
totpService *service.TotpService
}
// NewTotpHandler creates a new TotpHandler
func NewTotpHandler(totpService *service.TotpService) *TotpHandler {
return &TotpHandler{
totpService: totpService,
}
}
// TotpStatusResponse represents the TOTP status response
type TotpStatusResponse struct {
Enabled bool `json:"enabled"`
EnabledAt *int64 `json:"enabled_at,omitempty"` // Unix timestamp
FeatureEnabled bool `json:"feature_enabled"`
}
// GetStatus returns the TOTP status for the current user
// GET /api/v1/user/totp/status
func (h *TotpHandler) GetStatus(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
status, err := h.totpService.GetStatus(c.Request.Context(), subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp := TotpStatusResponse{
Enabled: status.Enabled,
FeatureEnabled: status.FeatureEnabled,
}
if status.EnabledAt != nil {
ts := status.EnabledAt.Unix()
resp.EnabledAt = &ts
}
response.Success(c, resp)
}
// TotpSetupRequest represents the request to initiate TOTP setup
type TotpSetupRequest struct {
EmailCode string `json:"email_code"`
Password string `json:"password"`
}
// TotpSetupResponse represents the TOTP setup response
type TotpSetupResponse struct {
Secret string `json:"secret"`
QRCodeURL string `json:"qr_code_url"`
SetupToken string `json:"setup_token"`
Countdown int `json:"countdown"`
}
// InitiateSetup starts the TOTP setup process
// POST /api/v1/user/totp/setup
func (h *TotpHandler) InitiateSetup(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
var req TotpSetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Allow empty body (optional params)
req = TotpSetupRequest{}
}
result, err := h.totpService.InitiateSetup(c.Request.Context(), subject.UserID, req.EmailCode, req.Password)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, TotpSetupResponse{
Secret: result.Secret,
QRCodeURL: result.QRCodeURL,
SetupToken: result.SetupToken,
Countdown: result.Countdown,
})
}
// TotpEnableRequest represents the request to enable TOTP
type TotpEnableRequest struct {
TotpCode string `json:"totp_code" binding:"required,len=6"`
SetupToken string `json:"setup_token" binding:"required"`
}
// Enable completes the TOTP setup
// POST /api/v1/user/totp/enable
func (h *TotpHandler) Enable(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
var req TotpEnableRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.totpService.CompleteSetup(c.Request.Context(), subject.UserID, req.TotpCode, req.SetupToken); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
// TotpDisableRequest represents the request to disable TOTP
type TotpDisableRequest struct {
EmailCode string `json:"email_code"`
Password string `json:"password"`
}
// Disable disables TOTP for the current user
// POST /api/v1/user/totp/disable
func (h *TotpHandler) Disable(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
var req TotpDisableRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.totpService.Disable(c.Request.Context(), subject.UserID, req.EmailCode, req.Password); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
// GetVerificationMethod returns the verification method for TOTP operations
// GET /api/v1/user/totp/verification-method
func (h *TotpHandler) GetVerificationMethod(c *gin.Context) {
method := h.totpService.GetVerificationMethod(c.Request.Context())
response.Success(c, method)
}
// SendVerifyCode sends an email verification code for TOTP operations
// POST /api/v1/user/totp/send-code
func (h *TotpHandler) SendVerifyCode(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
if err := h.totpService.SendVerifyCode(c.Request.Context(), subject.UserID); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
...@@ -70,6 +70,7 @@ func ProvideHandlers( ...@@ -70,6 +70,7 @@ func ProvideHandlers(
gatewayHandler *GatewayHandler, gatewayHandler *GatewayHandler,
openaiGatewayHandler *OpenAIGatewayHandler, openaiGatewayHandler *OpenAIGatewayHandler,
settingHandler *SettingHandler, settingHandler *SettingHandler,
totpHandler *TotpHandler,
) *Handlers { ) *Handlers {
return &Handlers{ return &Handlers{
Auth: authHandler, Auth: authHandler,
...@@ -82,6 +83,7 @@ func ProvideHandlers( ...@@ -82,6 +83,7 @@ func ProvideHandlers(
Gateway: gatewayHandler, Gateway: gatewayHandler,
OpenAIGateway: openaiGatewayHandler, OpenAIGateway: openaiGatewayHandler,
Setting: settingHandler, Setting: settingHandler,
Totp: totpHandler,
} }
} }
...@@ -96,6 +98,7 @@ var ProviderSet = wire.NewSet( ...@@ -96,6 +98,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionHandler, NewSubscriptionHandler,
NewGatewayHandler, NewGatewayHandler,
NewOpenAIGatewayHandler, NewOpenAIGatewayHandler,
NewTotpHandler,
ProvideSettingHandler, ProvideSettingHandler,
// Admin handlers // Admin handlers
......
//go:build integration
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
//
// Integration tests for verifying TLS fingerprint correctness.
// These tests make actual network requests to external services and should be run manually.
//
// Run with: go test -v -tags=integration ./internal/pkg/tlsfingerprint/...
package tlsfingerprint
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
// skipIfExternalServiceUnavailable checks if the external service is available.
// If not, it skips the test instead of failing.
func skipIfExternalServiceUnavailable(t *testing.T, err error) {
t.Helper()
if err != nil {
// Check for common network/TLS errors that indicate external service issues
errStr := err.Error()
if strings.Contains(errStr, "certificate has expired") ||
strings.Contains(errStr, "certificate is not yet valid") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "network is unreachable") ||
strings.Contains(errStr, "timeout") {
t.Skipf("skipping test: external service unavailable: %v", err)
}
t.Fatalf("failed to get fingerprint: %v", err)
}
}
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
func TestJA3Fingerprint(t *testing.T) {
// Skip if network is unavailable or if running in short mode
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := &Profile{
Name: "Claude CLI Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
// Use tls.peet.ws fingerprint detection API
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
var fpResp FingerprintResponse
if err := json.Unmarshal(body, &fpResp); err != nil {
t.Logf("Response body: %s", string(body))
t.Fatalf("failed to parse fingerprint response: %v", err)
}
// Log all fingerprint information
t.Logf("JA3: %s", fpResp.TLS.JA3)
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
t.Logf("JA4: %s", fpResp.TLS.JA4)
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
} else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
}
// Verify JA4 fingerprint
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
// The suffix _a33745022dd6_1f22a2ca17c4 should match
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
} else {
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
// Also accept 'i' variant for IP connections
altPrefix := "t13i5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else {
t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix)
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
} else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
}
// Verify extension list (should be 11 extensions including SNI)
// Expected: 0-11-10-35-16-22-23-13-43-45-51
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
} else {
t.Logf("Warning: JA3 extension list may differ")
}
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -tags=integration -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func TestAllProfiles(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
Profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
},
JA4CipherHash: "a33745022dd6", // stable part
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{
Name: "macos_arm64_node_v22180",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
},
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
},
}
for _, tc := range profiles {
tc := tc // capture range variable
t.Run(tc.Profile.Name, func(t *testing.T) {
fp := fetchFingerprint(t, tc.Profile)
if fp == nil {
return // fetchFingerprint already called t.Fatal
}
t.Logf("Profile: %s", tc.Profile.Name)
t.Logf(" JA3: %s", fp.JA3)
t.Logf(" JA3 Hash: %s", fp.JA3Hash)
t.Logf(" JA4: %s", fp.JA4)
t.Logf(" PeetPrint: %s", fp.PeetPrint)
t.Logf(" PeetPrintHash: %s", fp.PeetPrintHash)
// Verify expectations
if tc.ExpectedJA3 != "" {
if fp.JA3Hash == tc.ExpectedJA3 {
t.Logf(" ✓ JA3 hash matches: %s", tc.ExpectedJA3)
} else {
t.Errorf(" ✗ JA3 hash mismatch: got %s, expected %s", fp.JA3Hash, tc.ExpectedJA3)
}
}
if tc.ExpectedJA4 != "" {
if fp.JA4 == tc.ExpectedJA4 {
t.Logf(" ✓ JA4 matches: %s", tc.ExpectedJA4)
} else {
t.Errorf(" ✗ JA4 mismatch: got %s, expected %s", fp.JA4, tc.ExpectedJA4)
}
}
// Check JA4 cipher hash (stable middle part)
// JA4 format: prefix_cipherHash_extHash
if tc.JA4CipherHash != "" {
if strings.Contains(fp.JA4, "_"+tc.JA4CipherHash+"_") {
t.Logf(" ✓ JA4 cipher hash matches: %s", tc.JA4CipherHash)
} else {
t.Errorf(" ✗ JA4 cipher hash mismatch: got %s, expected cipher hash %s", fp.JA4, tc.JA4CipherHash)
}
}
})
}
}
// fetchFingerprint makes a request to tls.peet.ws and returns the TLS fingerprint info.
func fetchFingerprint(t *testing.T, profile *Profile) *TLSInfo {
t.Helper()
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
return nil
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
return nil
}
var fpResp FingerprintResponse
if err := json.Unmarshal(body, &fpResp); err != nil {
t.Logf("Response body: %s", string(body))
t.Fatalf("failed to parse fingerprint response: %v", err)
return nil
}
return &fpResp.TLS
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment